OpenAPI: first pass on Plugins API - Shared Links (#3378)

* Update depenedencies: OpenAPISpex + cursor based pagination

* Update formatter config

* Add internal server error implementation

* Test errors

* Implement pagination interface

* Implement Plugins API module macros

* Implement Public API base URI

(to be used with path helpers once called from within
forwarded router's scope)

* Implement OpenAPI specs + schemas

* Implement Shared Links context module

* Add pagination and error views

* Add Shared Link view

* Implement Shared Link controller

* Expose SharedLink.t() spec

* Implement separate router for the Plugins API

* Update moduledocs

* Always wrap resource objects with `data`

* Update moduledoc

* Use https://github.com/open-api-spex/open_api_spex/pull/425

due to https://github.com/open-api-spex/open_api_spex/issues/92

* Rely on BASE_URL for swagger-ui server definition

* Fixup goals migration

* Migrate broken goals before deleting dupes

* Remove bypassing test rate limiting for which there's none anyway

* Move the context module under `Plausible.` namespace

* Bring back conn assignment to PluginsAPICase template

* Update test/plausible_web/plugins/api/controllers/shared_links_test.exs

Co-authored-by: Uku Taht <Uku.taht@gmail.com>

* Update renamed aliases

* Seed static token for development purposes

* Delegate Plugins API 500s to a familiar shape

* Simplify with statement

---------

Co-authored-by: Uku Taht <Uku.taht@gmail.com>
This commit is contained in:
hq1 2023-10-02 11:18:49 +02:00 committed by GitHub
parent 5339db7152
commit 082ec91c63
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 1072 additions and 8 deletions

View File

@ -0,0 +1,54 @@
defmodule Plausible.Plugins.API.Context.SharedLinks do
@moduledoc """
Plugins API Context module for Shared Links.
All high level Shared Links operations should be implemented here.
"""
import Ecto.Query
import Plausible.Plugins.API.Pagination
alias Plausible.Repo
@spec get_shared_links(Plausible.Site.t(), map()) :: {:ok, Paginator.Page.t()}
def get_shared_links(site, params) do
query =
from l in Plausible.Site.SharedLink,
where: l.site_id == ^site.id,
order_by: [desc: l.id]
{:ok, paginate(query, params, cursor_fields: [{:id, :desc}])}
end
@spec get(Plausible.Site.t(), pos_integer() | String.t()) :: nil | Plausible.Site.SharedLink.t()
def get(site, id) when is_integer(id) do
get_by_id(site, id)
end
def get(site, name) when is_binary(name) do
get_by_name(site, name)
end
@spec get_or_create(Plausible.Site.t(), String.t(), String.t() | nil) ::
{:ok, Plausible.Site.SharedLink.t()}
def get_or_create(site, name, password \\ nil) do
case get_by_name(site, name) do
nil -> Plausible.Sites.create_shared_link(site, name, password)
shared_link -> {:ok, shared_link}
end
end
defp get_by_id(site, id) do
Repo.one(
from l in Plausible.Site.SharedLink,
where: l.site_id == ^site.id,
where: l.id == ^id
)
end
defp get_by_name(site, name) do
Repo.one(
from l in Plausible.Site.SharedLink,
where: l.site_id == ^site.id,
where: l.name == ^name
)
end
end

View File

@ -0,0 +1,43 @@
defmodule Plausible.Plugins.API.Pagination do
@moduledoc """
Cursor-based pagination for the Plugins API.
Can be moved to another namespace in case used elsewhere.
"""
@limit 10
@maximum_limit 100
@spec paginate(Ecto.Queryable.t(), map(), Keyword.t(), Keyword.t()) :: Paginator.Page.t()
def paginate(queryable, params, opts, repo_opts \\ []) do
opts = Keyword.merge([limit: @limit, maximum_limit: @maximum_limit], opts)
Paginator.paginate(
queryable,
Keyword.merge(opts, to_pagination_opts(params)),
Plausible.Repo,
repo_opts
)
end
defp to_pagination_opts(params) do
Enum.reduce(params, Keyword.new(), fn
{"after", cursor}, acc ->
Keyword.put(acc, :after, cursor)
{"before", cursor}, acc ->
Keyword.put(acc, :before, cursor)
{"limit", limit}, acc ->
limit = to_int(limit)
if limit > 0 and limit <= @maximum_limit do
Keyword.put(acc, :limit, limit)
else
acc
end
end)
end
defp to_int(x) when is_binary(x), do: String.to_integer(x)
defp to_int(x) when is_integer(x), do: x
end

View File

@ -10,10 +10,9 @@ defmodule Plausible.Plugins.API.Tokens do
import Ecto.Query
@spec create(Site.t(), String.t()) ::
{:ok, Token.t(), String.t()} | {:error, Ecto.Changeset.t()}
def create(%Site{} = site, description) do
with generated_token <- Token.generate(),
changeset <- Token.insert_changeset(site, generated_token, %{description: description}),
{:ok, Token.t(), String.t(), String.t()} | {:error, Ecto.Changeset.t()}
def create(%Site{} = site, description, generated_token \\ Token.generate()) do
with changeset <- Token.insert_changeset(site, generated_token, %{description: description}),
{:ok, saved_token} <- Repo.insert(changeset) do
{:ok, saved_token, generated_token.raw}
end

View File

@ -2,6 +2,8 @@ defmodule Plausible.Site.SharedLink do
use Ecto.Schema
import Ecto.Changeset
@type t() :: %__MODULE__{}
schema "shared_links" do
belongs_to :site, Plausible.Site
field :name, :string

View File

@ -43,6 +43,42 @@ defmodule PlausibleWeb do
end
end
def plugins_api_controller do
quote do
use Phoenix.Controller, namespace: PlausibleWeb.Plugins.API
import Plug.Conn
import PlausibleWeb.Plugins.API.Router.Helpers
import PlausibleWeb.Plugins.API, only: [base_uri: 0]
alias PlausibleWeb.Plugins.API.Schemas
alias PlausibleWeb.Plugins.API.Views
alias Plausible.Plugins.API.Context
plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true, replace_params: false)
use OpenApiSpex.ControllerSpecs
end
end
def plugins_api_view do
quote do
use Phoenix.View,
namespace: PlausibleWeb.Plugins.API,
root: ""
alias PlausibleWeb.Plugins.API.Router.Helpers
import PlausibleWeb.Plugins.API.Views.Pagination, only: [render_metadata_links: 4]
end
end
def open_api_schema do
quote do
require OpenApiSpex
alias OpenApiSpex.Schema
alias PlausibleWeb.Plugins.API.Schemas
end
end
@doc """
When used, dispatch to the appropriate controller/view/etc.
"""

View File

@ -0,0 +1,16 @@
defmodule PlausibleWeb.Plugins.API do
@moduledoc """
Plausible Plugins API
"""
@doc """
Returns the API base URI, so that complete URLs can
be generated from forwared Router helpers.
"""
@spec base_uri() :: URI.t()
def base_uri() do
PlausibleWeb.Endpoint.url()
|> Path.join("/api/plugins")
|> URI.new!()
end
end

View File

@ -0,0 +1,109 @@
defmodule PlausibleWeb.Plugins.API.Controllers.SharedLinks do
@moduledoc """
Controller for the Shared Link resource under Plugins API
"""
use PlausibleWeb, :plugins_api_controller
operation(:index,
summary: "Retrieve Shared Links",
parameters: [
limit: [in: :query, type: :integer, description: "Maximum entries per page", example: 10],
after: [
in: :query,
type: :string,
description: "Cursor value to seek after - generated internally"
],
before: [
in: :query,
type: :string,
description: "Cursor value to seek before - generated internally"
]
],
responses: %{
ok: {"Shared Links response", "application/json", Schemas.SharedLink.ListResponse},
unauthorized: {"Unauthorized", "application/json", Schemas.Unauthorized}
}
)
@spec index(Plug.Conn.t(), %{}) :: Plug.Conn.t()
def index(conn, _params) do
{:ok, pagination} =
Context.SharedLinks.get_shared_links(conn.assigns.authorized_site, conn.query_params)
conn
|> put_view(Views.SharedLink)
|> render("index.json", %{pagination: pagination})
end
operation(:create,
summary: "Create Shared Link",
request_body: {"Shared Link params", "application/json", Schemas.SharedLink.CreateRequest},
responses: %{
created: {"Shared Link", "application/json", Schemas.SharedLink},
unauthorized: {"Unauthorized", "application/json", Schemas.Unauthorized},
unprocessable_entity:
{"Unprocessable entity", "application/json", Schemas.UnprocessableEntity}
}
)
@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
def create(
%{
private: %{
open_api_spex: %{
body_params: %Schemas.SharedLink.CreateRequest{name: name, password: password}
}
}
} = conn,
_params
) do
site = conn.assigns.authorized_site
{:ok, shared_link} = Context.SharedLinks.get_or_create(site, name, password)
conn
|> put_view(Views.SharedLink)
|> put_status(:created)
|> put_resp_header("location", shared_links_url(base_uri(), :get, shared_link.id))
|> render("shared_link.json", shared_link: shared_link, authorized_site: site)
end
operation(:get,
summary: "Retrieve Shared Link by ID",
parameters: [
id: [
in: :path,
type: :integer,
description: "Shared Link ID",
example: 123,
required: true
]
],
responses: %{
created: {"Shared Link", "application/json", Schemas.SharedLink},
not_found: {"NotFound", "application/json", Schemas.NotFound},
unauthorized: {"Unauthorized", "application/json", Schemas.Unauthorized},
unprocessable_entity:
{"Unprocessable entity", "application/json", Schemas.UnprocessableEntity}
}
)
@spec get(Plug.Conn.t(), map()) :: Plug.Conn.t()
def get(%{private: %{open_api_spex: %{params: %{id: id}}}} = conn, _params) do
site = conn.assigns.authorized_site
case Context.SharedLinks.get(site, id) do
nil ->
conn
|> put_view(Views.Error)
|> put_status(:not_found)
|> render("404.json")
shared_link ->
conn
|> put_view(Views.SharedLink)
|> put_status(:ok)
|> render("shared_link.json", shared_link: shared_link, authorized_site: site)
end
end
end

View File

@ -0,0 +1,26 @@
defmodule PlausibleWeb.Plugins.API.Router do
use PlausibleWeb, :router
pipeline :auth do
plug(PlausibleWeb.Plugs.AuthorizePluginsAPI)
end
pipeline :api do
plug(:accepts, ["json"])
plug(OpenApiSpex.Plug.PutApiSpec, module: PlausibleWeb.Plugins.API.Spec)
end
scope "/spec" do
pipe_through(:api)
get("/openapi", OpenApiSpex.Plug.RenderSpec, [])
get("/swagger-ui", OpenApiSpex.Plug.SwaggerUI, path: "/api/plugins/spec/openapi")
end
scope "/v1", PlausibleWeb.Plugins.API.Controllers do
pipe_through([:api, :auth])
get("/shared_links", SharedLinks, :index)
get("/shared_links/:id", SharedLinks, :get)
post("/shared_links", SharedLinks, :create)
end
end

View File

@ -0,0 +1,16 @@
defmodule PlausibleWeb.Plugins.API.Schemas.Error do
@moduledoc """
OpenAPI schema for an error included in a response
"""
use PlausibleWeb, :open_api_schema
OpenApiSpex.schema(%{
description: """
An explanation of an error that occurred within the Plugins API
""",
type: :object,
required: [:detail],
properties: %{detail: %OpenApiSpex.Schema{type: :string}}
})
end

View File

@ -0,0 +1,13 @@
defmodule PlausibleWeb.Plugins.API.Schemas.Link do
@moduledoc """
OpenAPI Link schema
"""
use PlausibleWeb, :open_api_schema
OpenApiSpex.schema(%{
title: "Link",
type: :object,
required: [:url],
properties: %{url: %OpenApiSpex.Schema{type: :string}}
})
end

View File

@ -0,0 +1,22 @@
defmodule PlausibleWeb.Plugins.API.Schemas.NotFound do
@moduledoc """
OpenAPI schema for a generic 404 response
"""
use PlausibleWeb, :open_api_schema
OpenApiSpex.schema(%{
description: """
The response that is returned when the user makes a request to a non-existing resource
""",
type: :object,
title: "NotFoundError",
required: [:errors],
properties: %{
errors: %OpenApiSpex.Schema{
items: Schemas.Error,
type: :array
}
}
})
end

View File

@ -0,0 +1,25 @@
defmodule PlausibleWeb.Plugins.API.Schemas.PaginationMetadata do
@moduledoc """
Pagination metadata OpenAPI schema
"""
use PlausibleWeb, :open_api_schema
OpenApiSpex.schema(%{
title: "PaginationMetadata",
description: "Pagination meta data",
type: :object,
required: [:has_next_page, :has_prev_page],
properties: %{
has_next_page: %OpenApiSpex.Schema{type: :boolean},
has_prev_page: %OpenApiSpex.Schema{type: :boolean},
links: %OpenApiSpex.Schema{
items: Schemas.Link,
properties: %{
next: %OpenApiSpex.Reference{"$ref": "#/components/schemas/Link"},
prev: %OpenApiSpex.Reference{"$ref": "#/components/schemas/Link"}
},
type: :object
}
}
})
end

View File

@ -0,0 +1,27 @@
defmodule PlausibleWeb.Plugins.API.Schemas.SharedLink do
@moduledoc """
OpenAPI schema for SharedLink object
"""
use PlausibleWeb, :open_api_schema
OpenApiSpex.schema(%{
description: "Shared Link object",
type: :object,
required: [:data],
properties: %{
data: %Schema{
type: :object,
required: [:id, :name, :password_protected, :href],
properties: %{
id: %Schema{type: :integer, description: "Shared Link ID"},
name: %Schema{type: :string, description: "Shared Link Name"},
password_protected: %Schema{
type: :boolean,
description: "Shared Link Has Password"
},
href: %Schema{type: :string, description: "Shared Link URL"}
}
}
}
})
end

View File

@ -0,0 +1,20 @@
defmodule PlausibleWeb.Plugins.API.Schemas.SharedLink.CreateRequest do
@moduledoc """
OpenAPI schema for SharedLink creation request
"""
use PlausibleWeb, :open_api_schema
OpenApiSpex.schema(%{
title: "SharedLink.CreateRequest",
description: "Shared Links creation params",
type: :object,
required: [:name],
properties: %{
name: %Schema{description: "Shared Link Name", type: :string},
password: %Schema{description: "Shared Link Password", type: :string}
},
example: %{
name: "My Shared Dashboard"
}
})
end

View File

@ -0,0 +1,29 @@
defmodule PlausibleWeb.Plugins.API.Schemas.SharedLink.ListResponse do
@moduledoc """
OpenAPI schema for SharedLink list response
"""
use PlausibleWeb, :open_api_schema
OpenApiSpex.schema(%{
title: "SharedLink.ListResponse",
description: "Shared Links list response",
type: :object,
required: [:data, :meta],
properties: %{
data: %Schema{
items: Schemas.SharedLink,
type: :array
},
meta: %OpenApiSpex.Schema{
required: [:pagination],
properties: %{
pagination: %OpenApiSpex.Reference{
"$ref": "#/components/schemas/PaginationMetadata"
}
},
type: :object,
items: Schemas.PaginationMetadata
}
}
})
end

View File

@ -0,0 +1,22 @@
defmodule PlausibleWeb.Plugins.API.Schemas.Unauthorized do
@moduledoc """
OpenAPI schema for a generic 401 response
"""
use PlausibleWeb, :open_api_schema
OpenApiSpex.schema(%{
description: """
The response that is returned when the user makes an unauthorized request.
""",
type: :object,
title: "UnauthorizedError",
required: [:errors],
properties: %{
errors: %OpenApiSpex.Schema{
items: Schemas.Error,
type: :array
}
}
})
end

View File

@ -0,0 +1,23 @@
defmodule PlausibleWeb.Plugins.API.Schemas.UnprocessableEntity do
@moduledoc """
OpenAPI schema for a generic 422 response
"""
use PlausibleWeb, :open_api_schema
OpenApiSpex.schema(%{
description: """
The response that is returned when the user makes a request that cannot be
processed.
""",
type: :object,
title: "UnprocessableEntityError",
required: [:errors],
properties: %{
errors: %OpenApiSpex.Schema{
items: Schemas.Error,
type: :array
}
}
})
end

View File

@ -0,0 +1,45 @@
defmodule PlausibleWeb.Plugins.API.Spec do
@moduledoc """
OpenAPI specification for the Plugins API
"""
alias OpenApiSpex.{Components, Info, OpenApi, Paths, Server}
alias PlausibleWeb.Plugins.API.Router
@behaviour OpenApi
@impl OpenApi
def spec do
%OpenApi{
servers: [
%Server{
description: "This server",
url: to_string(PlausibleWeb.Plugins.API.base_uri()),
variables: %{}
}
],
info: %Info{
title: "Plausible Plugins API",
version: "1.0-rc"
},
# Populate the paths from a phoenix router
paths: Paths.from_router(Router),
components: %Components{
securitySchemes: %{
"basic_auth" => %OpenApiSpex.SecurityScheme{
type: "http",
scheme: "basic",
description: """
HTTP basic access authentication using your Site domain as the
username and the Plugins API Token contents as the password.
For more information see
https://en.wikipedia.org/wiki/Basic_access_authentication
"""
}
}
},
security: [%{"basic_auth" => []}]
}
# Discover request/response schemas from path specs
|> OpenApiSpex.resolve_schema_modules()
end
end

View File

@ -0,0 +1,23 @@
defmodule PlausibleWeb.Plugins.API.Views.Error do
@moduledoc """
View for rendering Plugins REST API errors
"""
use PlausibleWeb, :plugins_api_view
def template_not_found(_template, assigns) do
render("500.json", assigns)
end
@spec render(String.t(), map) :: map | binary()
def render("400.json", _assigns) do
%{errors: [%{detail: "Bad request"}]}
end
def render("404.json", _assigns) do
%{errors: [%{detail: "Plugins API: resource not found"}]}
end
def render("500.json", _assigns) do
%{errors: [%{detail: "Plugins API: Internal server error"}]}
end
end

View File

@ -0,0 +1,66 @@
defmodule PlausibleWeb.Plugins.API.Views.Pagination do
@moduledoc """
A view capable of rendering pagination metadata included
in responses containing lists of objects.
"""
use Phoenix.View,
namespace: PlausibleWeb.Plugins.API,
root: ""
alias PlausibleWeb.Plugins.API.Router.Helpers
def render_metadata_links(meta, helper_fn, helper_fn_args, existing_params \\ %{}) do
render(__MODULE__, "pagination.json", %{
meta: meta,
url_helper: fn query_params ->
existing_params = Map.drop(existing_params, ["before", "after"])
query_params =
query_params
|> Enum.into(%{})
|> Map.merge(existing_params)
args = [
PlausibleWeb.Plugins.API.base_uri()
| List.wrap(helper_fn_args) ++ [query_params]
]
apply(Helpers, helper_fn, args)
end
})
end
@spec render(binary(), map()) ::
binary()
def render("pagination.json", %{meta: meta, url_helper: url_helper_fn}) do
pagination =
[
{:after, :next, :has_next_page},
{:before, :prev, :has_prev_page}
]
|> Enum.reduce(%{}, fn
{meta_key, url_key, sibling_key}, acc ->
meta_value = Map.get(meta, meta_key)
if meta_value do
url = url_helper_fn.([{meta_key, meta_value}])
acc
|> Map.update(
:links,
%{url_key => %{url: url}},
&Map.put(&1, url_key, %{url: url})
)
|> Map.put(sibling_key, true)
else
acc
|> Map.update(:links, %{}, & &1)
|> Map.put(sibling_key, false)
end
end)
%{
pagination: pagination
}
end
end

View File

@ -0,0 +1,32 @@
defmodule PlausibleWeb.Plugins.API.Views.SharedLink do
@moduledoc """
View for rendering Shared Links in the Plugins API
"""
use PlausibleWeb, :plugins_api_view
def render("index.json", %{
pagination: %{entries: shared_links, metadata: metadata},
authorized_site: site,
conn: conn
}) do
%{
data: render_many(shared_links, __MODULE__, "shared_link.json", authorized_site: site),
meta: render_metadata_links(metadata, :shared_links_url, :index, conn.query_params)
}
end
def render("shared_link.json", %{
shared_link: shared_link,
authorized_site: site
}) do
%{
data: %{
id: shared_link.id,
name: shared_link.name,
password_protected: is_binary(shared_link.password_hash),
href: Plausible.Sites.shared_link_url(site, shared_link)
}
}
end
end

View File

@ -67,6 +67,10 @@ defmodule PlausibleWeb.Router do
forward "/", FunWithFlags.UI.Router, namespace: "flags"
end
scope path: "/api/plugins" do
forward "/", PlausibleWeb.Plugins.API.Router
end
scope "/api/stats", PlausibleWeb.Api do
pipe_through :internal_stats_api
get "/:domain/funnels/:id", StatsController, :funnel

View File

@ -1,6 +1,19 @@
defmodule PlausibleWeb.ErrorView do
use PlausibleWeb, :view
def render("500.json", %{conn: %{private: %{PlausibleWeb.Plugins.API.Router => _}}}) do
contact_support_note =
if not Plausible.Release.selfhost?() do
"If the problem persists please contact support@plausible.io"
end
%{
errors: [
%{detail: "Internal server error, please try again. #{contact_support_note}"}
]
}
end
def render("500.json", _assigns) do
%{
status: 500,

View File

@ -122,7 +122,9 @@ defmodule Plausible.MixProject do
{:mjml_eex, "~> 0.9.0"},
{:mjml, "~> 1.5.0"},
{:heroicons, "~> 0.5.0"},
{:zxcvbn, git: "https://github.com/techgaun/zxcvbn-elixir.git"}
{:zxcvbn, git: "https://github.com/techgaun/zxcvbn-elixir.git"},
{:open_api_spex, "~> 3.18"},
{:paginator, git: "https://github.com/duffelhq/paginator.git"}
]
end

View File

@ -92,6 +92,7 @@
"oban": {:hex, :oban, "2.12.1", "f604d7e6a8be9fda4a9b0f6cebbd633deba569f85dbff70c4d25d99a6f023177", [:mix], [{:ecto_sql, "~> 3.6", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b1844c2b74e0d788b73e5144b0c9d5674cb775eae29d88a36f3c3b48d42d058"},
"observer_cli": {:hex, :observer_cli, "1.7.3", "25d094d485f47239f218b53df0691a102fef13071dfd0d04922b5142297cfc93", [:mix, :rebar3], [{:recon, "~>2.5.1", [hex: :recon, repo: "hexpm", optional: false]}], "hexpm", "a41b6d3e11a3444e063e09cc225f7f3e631ce14019e5fbcaebfda89b1bd788ea"},
"octo_fetch": {:hex, :octo_fetch, "0.3.0", "89ff501d2ac0448556ff1931634a538fe6d6cd358ba827ce1747e6a42a46efbf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "c07e44f2214ab153743b7b3182f380798d0b294b1f283811c1e30cff64096d3d"},
"open_api_spex": {:hex, :open_api_spex, "3.18.0", "f9952b6bc8a1bf14168f3754981b7c8d72d015112bfedf2588471dd602e1e715", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "37849887ab67efab052376401fac28c0974b273ffaecd98f4532455ca0886464"},
"opentelemetry": {:hex, :opentelemetry, "1.1.1", "02de53d7dcafc087793ddf98cac946aaaa13c99cb6a7e568d9bb5ce4552b340e", [:rebar3], [{:opentelemetry_api, "~> 1.1", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "43a807d536bca55542731ddb5ecf68c0b3b433ff98713a6496058075bac70031"},
"opentelemetry_api": {:hex, :opentelemetry_api, "1.1.0", "156366bfbf249f54daf2626e087e29ad91201eab670993fd9ae1bd278d03a096", [:mix, :rebar3], [], "hexpm", "e0d0b49e21e5785da675c97104c385283cae84fcc0d8522932a5dcf55489ead1"},
"opentelemetry_ecto": {:hex, :opentelemetry_ecto, "1.0.0", "8463cc466429b3e125ae236a5cc32869ea38c88eb2f07722dd88e78ceca6963c", [:mix], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.1.0", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "39cc60dbdd54e371bd12fb010ee960de851d782dca197efefa5c35eb6b4c18bd"},
@ -100,6 +101,7 @@
"opentelemetry_phoenix": {:hex, :opentelemetry_phoenix, "1.0.0", "73b8fee8bca6ac7a6aa0b17f61eb63494d1c78fffccdbac9656d92d279f9980c", [:mix], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_telemetry, "~> 1.0", [hex: :opentelemetry_telemetry, repo: "hexpm", optional: false]}, {:plug, ">= 1.11.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9cb30018fda95657f5698130541988b68269de2c9a870051f845f7e2193e72c3"},
"opentelemetry_process_propagator": {:hex, :opentelemetry_process_propagator, "0.1.1", "81ec6825971903486ee73be23230d06764df39ee11011e520f601aa2bb21c893", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "0572f26066bbb0457e22e169f966c0140a8f95237716c9c6ba4458d6dbaa724b"},
"opentelemetry_telemetry": {:hex, :opentelemetry_telemetry, "1.0.0", "d5982a319e725fcd2305b306b65c18a86afdcf7d96821473cf0649ff88877615", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.3.0", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "3401d13a1d4b7aa941a77e6b3ec074f0ae77f83b5b2206766ce630123a9291a9"},
"paginator": {:git, "https://github.com/duffelhq/paginator.git", "3508d6ad77a95ac1faf15d5fd7f959fab3e17da2", []},
"parallel_stream": {:hex, :parallel_stream, "1.1.0", "f52f73eb344bc22de335992377413138405796e0d0ad99d995d9977ac29f1ca9", [:mix], [], "hexpm", "684fd19191aedfaf387bbabbeb8ff3c752f0220c8112eb907d797f4592d6e871"},
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
"phoenix": {:hex, :phoenix, "1.7.7", "4cc501d4d823015007ba3cdd9c41ecaaf2ffb619d6fb283199fa8ddba89191e0", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "8966e15c395e5e37591b6ed0bd2ae7f48e961f0f60ac4c733f9566b519453085"},

View File

@ -1,4 +1,4 @@
[
import_deps: [:ecto_sql],
import_deps: [:ecto_sql, :open_api_spex],
inputs: ["*.exs"]
]

View File

@ -18,8 +18,8 @@ defmodule Plausible.Repo.Migrations.GoalsUnique do
GROUP BY
(g2.site_id,
CASE
WHEN g2.page_path IS NOT NULL THEN g2.page_path
WHEN g2.event_name IS NOT NULL THEN g2.event_name
WHEN g2.page_path IS NOT NULL THEN 'Page: ' || g2.page_path
WHEN g2.event_name IS NOT NULL THEN 'Event: ' || g2.event_name
END )
)
AND g.id NOT IN (

View File

@ -42,6 +42,12 @@ site =
stats_start_date: NaiveDateTime.new!(imported_stats_range.first, ~T[00:00:00])
)
# Plugins API: on dev environment, use "plausible-plugin-dev-seed-token" for "dummy.site" to authenticate
seeded_token = Plausible.Plugins.API.Token.generate("seed-token")
{:ok, _, _} =
Plausible.Plugins.API.Tokens.create(site, "plausible-plugin-dev-seed-token", seeded_token)
{:ok, goal1} = Plausible.Goals.create(site, %{"page_path" => "/"})
{:ok, goal2} = Plausible.Goals.create(site, %{"page_path" => "/register"})
{:ok, goal3} = Plausible.Goals.create(site, %{"page_path" => "/login"})

View File

@ -0,0 +1,64 @@
defmodule Plausible.Plugins.API.PaginationTest do
use Plausible.DataCase, async: true
alias Plausible.Plugins.API.Pagination
import Ecto.Query
setup do
sites = insert_list(12, :site)
{:ok, %{sites: sites, query: from(s in Plausible.Site, order_by: [desc: :id])}}
end
test "default page size", %{query: q} do
pagination = Pagination.paginate(q, %{}, cursor_fields: [id: :desc])
assert Enum.count(pagination.entries) == 10
assert pagination.metadata.after
assert pagination.metadata.limit == 10
refute pagination.metadata.before
end
test "limit can be overriden", %{query: q} do
pagination = Pagination.paginate(q, %{"limit" => 3}, cursor_fields: [id: :desc])
assert Enum.count(pagination.entries) == 3
assert pagination.metadata.limit == 3
end
test "limit exceeds all entries count", %{query: q, sites: sites} do
pagination = Pagination.paginate(q, %{"limit" => 100}, cursor_fields: [id: :desc])
assert Enum.count(pagination.entries) == Enum.count(sites)
end
test "user provided limit exceeeds maximum limit", %{query: q} do
pagination = Pagination.paginate(q, %{"limit" => 200}, cursor_fields: [id: :desc])
assert pagination.metadata.limit == 10
end
test "limit supplied as a string", %{query: q} do
pagination = Pagination.paginate(q, %{"limit" => "3"}, cursor_fields: [id: :desc])
assert Enum.count(pagination.entries) == 3
assert pagination.metadata.limit == 3
end
test "next/prev page", %{query: q} do
page1 = Pagination.paginate(q, %{"limit" => 3}, cursor_fields: [id: :desc])
page_after = page1.metadata.after
page2 =
Pagination.paginate(q, %{"limit" => 3, "after" => page_after}, cursor_fields: [id: :desc])
assert page1.entries != page2.entries
assert Enum.count(page1.entries) == Enum.count(page2.entries)
page_before = page2.metadata.before
assert ^page1 =
Pagination.paginate(q, %{"limit" => 3, "before" => page_before},
cursor_fields: [id: :desc]
)
end
end

View File

@ -0,0 +1,238 @@
defmodule PlausibleWeb.Plugins.API.Controllers.SharedLinksTest do
use PlausibleWeb.PluginsAPICase, async: true
setup %{test: test} do
site = insert(:site)
{:ok, _token, raw_token} = Plausible.Plugins.API.Tokens.create(site, Atom.to_string(test))
{:ok,
%{
site: site,
token: raw_token
}}
end
describe "unathorized calls" do
for {method, url} <- [
{:get, Routes.shared_links_url(base_uri(), :get, 1)},
{:post, Routes.shared_links_url(base_uri(), :create)}
] do
test "unauthorized call to #{url}", %{conn: conn} do
conn
|> unquote(method)(unquote(url))
|> json_response(401)
|> assert_schema("UnauthorizedError", spec())
end
end
end
describe "get /shared_links/:id" do
test "validates input out of the box", %{conn: conn, token: token, site: site} do
url = Routes.shared_links_url(base_uri(), :get, "hello")
resp =
conn
|> authenticate(site.domain, token)
|> get(url)
|> json_response(422)
|> assert_schema("UnprocessableEntityError", spec())
assert %{errors: [%{detail: "Invalid integer. Got: string"}]} = resp
end
test "retrieve shared link by ID", %{conn: conn, site: site, token: token} do
shared_link = insert(:shared_link, name: "Some Link Name", site: site)
url = Routes.shared_links_url(base_uri(), :get, shared_link.id)
resp =
conn
|> authenticate(site.domain, token)
|> get(url)
|> json_response(200)
|> assert_schema("SharedLink", spec())
assert resp.data.href ==
"http://localhost:8000/share/#{site.domain}?auth=#{shared_link.slug}"
assert resp.data.id == shared_link.id
assert resp.data.password_protected == false
assert resp.data.name == "Some Link Name"
end
test "fails to retrieve non-existing link", %{conn: conn, site: site, token: token} do
url = Routes.shared_links_url(base_uri(), :get, 666)
conn
|> authenticate(site.domain, token)
|> get(url)
|> json_response(404)
|> assert_schema("NotFoundError", spec())
end
test "fails to retrieve link from another site", %{conn: conn, site: site, token: token} do
shared_link = insert(:shared_link, name: "Some Link Name", site: build(:site))
url = Routes.shared_links_url(base_uri(), :get, shared_link.id)
conn
|> authenticate(site.domain, token)
|> get(url)
|> json_response(404)
|> assert_schema("NotFoundError", spec())
end
end
describe "post /shared_links" do
test "successfully creates a shared link with the location header", %{
conn: conn,
site: site,
token: token
} do
url = Routes.shared_links_url(base_uri(), :create)
initial_conn = authenticate(conn, site.domain, token)
conn =
initial_conn
|> put_req_header("content-type", "application/json")
|> post(url, %{
name: "My Shared Link"
})
resp =
conn
|> json_response(201)
|> assert_schema("SharedLink", spec())
assert resp.data.name == "My Shared Link"
assert resp.data.href =~ "http://localhost:8000/share/#{site.domain}?auth="
[location] = get_resp_header(conn, "location")
assert location ==
Routes.shared_links_url(base_uri(), :get, resp.data.id)
assert ^resp =
initial_conn
|> get(location)
|> json_response(200)
|> assert_schema("SharedLink", spec())
end
test "create is idempotent", %{
conn: conn,
site: site,
token: token
} do
url = Routes.shared_links_url(base_uri(), :create)
initial_conn = authenticate(conn, site.domain, token)
create = fn ->
initial_conn
|> put_req_header("content-type", "application/json")
|> post(url, %{
name: "My Shared Link"
})
end
conn = create.()
resp =
conn
|> json_response(201)
|> assert_schema("SharedLink", spec())
id = resp.data.id
conn = create.()
assert ^id =
conn
|> json_response(201)
|> Map.fetch!("data")
|> Map.fetch!("id")
end
test "validates input out of the box", %{conn: conn, token: token, site: site} do
url = Routes.shared_links_url(base_uri(), :create)
resp =
conn
|> authenticate(site.domain, token)
|> put_req_header("content-type", "application/json")
|> post(url, %{})
|> json_response(422)
|> assert_schema("UnprocessableEntityError", spec())
assert %{errors: [%{detail: "Missing field: name"}]} = resp
end
end
describe "get /shared_links" do
test "returns an empty shared link list if there's none", %{
conn: conn,
token: token,
site: site
} do
url = Routes.shared_links_url(base_uri(), :index)
resp =
conn
|> authenticate(site.domain, token)
|> get(url)
|> json_response(200)
|> assert_schema("SharedLink.ListResponse", spec())
assert resp.data == []
assert resp.meta.pagination.has_next_page == false
assert resp.meta.pagination.has_prev_page == false
assert resp.meta.pagination.links == %{}
end
test "returns a shared links list with pagination", %{
conn: conn,
token: token,
site: site
} do
for i <- 1..5 do
insert(:shared_link, site: site, name: "Shared Link #{i}")
end
url = Routes.shared_links_url(base_uri(), :index, limit: 2)
initial_conn = authenticate(conn, site.domain, token)
page1 =
initial_conn
|> get(url)
|> json_response(200)
|> assert_schema("SharedLink.ListResponse", spec())
assert [%{data: %{name: "Shared Link 5"}}, %{data: %{name: "Shared Link 4"}}] = page1.data
assert page1.meta.pagination.has_next_page == true
assert page1.meta.pagination.has_prev_page == false
assert page1.meta.pagination.links.next
refute page1.meta.pagination.links[:prev]
page2 =
initial_conn
|> get(page1.meta.pagination.links.next.url)
|> json_response(200)
|> assert_schema("SharedLink.ListResponse", spec())
assert [%{data: %{name: "Shared Link 3"}}, %{data: %{name: "Shared Link 2"}}] = page2.data
assert page2.meta.pagination.has_next_page == true
assert page2.meta.pagination.has_prev_page == true
assert page2.meta.pagination.links.next
assert page2.meta.pagination.links.prev
assert ^page1 =
initial_conn
|> get(page2.meta.pagination.links.prev.url)
|> json_response(200)
|> assert_schema("SharedLink.ListResponse", spec())
end
end
end

View File

@ -0,0 +1,41 @@
defmodule PlausibleWeb.Plugins.API.ErrorsTest do
use ExUnit.Case, async: true
import Phoenix.ConnTest, only: [json_response: 2]
alias PlausibleWeb.Plugins.API.Errors
describe "unauthorized/1" do
test "sends an 401 response with the `www-authenticate` header set" do
conn =
Plug.Test.conn(:get, "/")
|> Errors.unauthorized()
assert conn.halted
assert json_response(conn, 401) == %{
"errors" => [%{"detail" => "Plugins API: unauthorized"}]
}
assert Plug.Conn.get_resp_header(conn, "www-authenticate") == [
~s[Basic realm="Plugins API Access"]
]
end
end
describe "error/3" do
test "formats the given error message" do
message = "Some message"
conn =
Plug.Test.conn(:get, "/")
|> Errors.error(:forbidden, message)
assert conn.halted
assert json_response(conn, 403) == %{
"errors" => [%{"detail" => "Some message"}]
}
end
end
end

View File

@ -0,0 +1,46 @@
defmodule PlausibleWeb.PluginsAPICase do
@moduledoc """
This module defines the test case to be used by
tests that require setting up a Plugins API connection.
"""
use ExUnit.CaseTemplate
using do
quote do
# The default endpoint for testing
@endpoint PlausibleWeb.Endpoint
# Import conveniences for testing with connections
use Plausible.TestUtils
import Plug.Conn
import Phoenix.ConnTest
import PlausibleWeb.Plugins.API, only: [base_uri: 0]
import PlausibleWeb.Plugins.API.Spec, only: [spec: 0]
alias PlausibleWeb.Plugins.API.Router.Helpers, as: Routes
import Plausible.Factory
import OpenApiSpex.TestAssertions
def authenticate(conn, domain, raw_token) do
conn
|> Plug.Conn.put_req_header(
"authorization",
Plug.BasicAuth.encode_basic_auth(domain, raw_token)
)
end
end
end
setup tags do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Plausible.Repo)
unless tags[:async] do
Ecto.Adapters.SQL.Sandbox.mode(Plausible.Repo, {:shared, self()})
end
conn = Phoenix.ConnTest.build_conn()
{:ok, conn: conn}
end
end