mirror of
https://github.com/plausible/analytics.git
synced 2024-12-27 11:33:31 +03:00
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:
parent
5339db7152
commit
082ec91c63
54
lib/plausible/plugins/api/context/shared_links.ex
Normal file
54
lib/plausible/plugins/api/context/shared_links.ex
Normal 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
|
43
lib/plausible/plugins/api/pagination.ex
Normal file
43
lib/plausible/plugins/api/pagination.ex
Normal 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
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
"""
|
||||
|
16
lib/plausible_web/plugins/api.ex
Normal file
16
lib/plausible_web/plugins/api.ex
Normal 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
|
109
lib/plausible_web/plugins/api/controllers/shared_links.ex
Normal file
109
lib/plausible_web/plugins/api/controllers/shared_links.ex
Normal 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
|
26
lib/plausible_web/plugins/api/router.ex
Normal file
26
lib/plausible_web/plugins/api/router.ex
Normal 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
|
16
lib/plausible_web/plugins/api/schemas/error.ex
Normal file
16
lib/plausible_web/plugins/api/schemas/error.ex
Normal 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
|
13
lib/plausible_web/plugins/api/schemas/link.ex
Normal file
13
lib/plausible_web/plugins/api/schemas/link.ex
Normal 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
|
22
lib/plausible_web/plugins/api/schemas/not_found.ex
Normal file
22
lib/plausible_web/plugins/api/schemas/not_found.ex
Normal 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
|
25
lib/plausible_web/plugins/api/schemas/pagination_metadata.ex
Normal file
25
lib/plausible_web/plugins/api/schemas/pagination_metadata.ex
Normal 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
|
27
lib/plausible_web/plugins/api/schemas/shared_link.ex
Normal file
27
lib/plausible_web/plugins/api/schemas/shared_link.ex
Normal 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
|
@ -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
|
@ -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
|
22
lib/plausible_web/plugins/api/schemas/unauthorized.ex
Normal file
22
lib/plausible_web/plugins/api/schemas/unauthorized.ex
Normal 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
|
@ -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
|
45
lib/plausible_web/plugins/api/spec.ex
Normal file
45
lib/plausible_web/plugins/api/spec.ex
Normal 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
|
23
lib/plausible_web/plugins/api/views/error.ex
Normal file
23
lib/plausible_web/plugins/api/views/error.ex
Normal 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
|
66
lib/plausible_web/plugins/api/views/pagination.ex
Normal file
66
lib/plausible_web/plugins/api/views/pagination.ex
Normal 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
|
32
lib/plausible_web/plugins/api/views/shared_link.ex
Normal file
32
lib/plausible_web/plugins/api/views/shared_link.ex
Normal 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
|
@ -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
|
||||
|
@ -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,
|
||||
|
4
mix.exs
4
mix.exs
@ -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
|
||||
|
||||
|
2
mix.lock
2
mix.lock
@ -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"},
|
||||
|
@ -1,4 +1,4 @@
|
||||
[
|
||||
import_deps: [:ecto_sql],
|
||||
import_deps: [:ecto_sql, :open_api_spex],
|
||||
inputs: ["*.exs"]
|
||||
]
|
||||
|
@ -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 (
|
||||
|
@ -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"})
|
||||
|
64
test/plausible/plugins/api/pagination_test.exs
Normal file
64
test/plausible/plugins/api/pagination_test.exs
Normal 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
|
238
test/plausible_web/plugins/api/controllers/shared_links_test.exs
Normal file
238
test/plausible_web/plugins/api/controllers/shared_links_test.exs
Normal 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
|
41
test/plausible_web/plugins/api/errors_test.exs
Normal file
41
test/plausible_web/plugins/api/errors_test.exs
Normal 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
|
46
test/support/plugins_api_case.ex
Normal file
46
test/support/plugins_api_case.ex
Normal 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
|
Loading…
Reference in New Issue
Block a user