Skip to content

Commit

Permalink
OpenAPI: first pass on Plugins API - Shared Links (#3378)
Browse files Browse the repository at this point in the history
* 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 open-api-spex/open_api_spex#425

due to open-api-spex/open_api_spex#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>
  • Loading branch information
aerosol and ukutaht authored Oct 2, 2023
1 parent 5339db7 commit 082ec91
Show file tree
Hide file tree
Showing 33 changed files with 1,072 additions and 8 deletions.
54 changes: 54 additions & 0 deletions lib/plausible/plugins/api/context/shared_links.ex
Original file line number Diff line number Diff line change
@@ -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 changes: 43 additions & 0 deletions lib/plausible/plugins/api/pagination.ex
Original file line number Diff line number Diff line change
@@ -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
7 changes: 3 additions & 4 deletions lib/plausible/plugins/api/tokens.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lib/plausible/site/shared_link.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions lib/plausible_web.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down
16 changes: 16 additions & 0 deletions lib/plausible_web/plugins/api.ex
Original file line number Diff line number Diff line change
@@ -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 changes: 109 additions & 0 deletions lib/plausible_web/plugins/api/controllers/shared_links.ex
Original file line number Diff line number Diff line change
@@ -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 changes: 26 additions & 0 deletions lib/plausible_web/plugins/api/router.ex
Original file line number Diff line number Diff line change
@@ -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 changes: 16 additions & 0 deletions lib/plausible_web/plugins/api/schemas/error.ex
Original file line number Diff line number Diff line change
@@ -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 changes: 13 additions & 0 deletions lib/plausible_web/plugins/api/schemas/link.ex
Original file line number Diff line number Diff line change
@@ -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 changes: 22 additions & 0 deletions lib/plausible_web/plugins/api/schemas/not_found.ex
Original file line number Diff line number Diff line change
@@ -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 changes: 25 additions & 0 deletions lib/plausible_web/plugins/api/schemas/pagination_metadata.ex
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 082ec91

Please sign in to comment.