Add GET /capabilities to Plugins API (#3808)

* Add `GET /capabilities` to Plugins API

It aims to:

 - help the client verify the data-domain the token is associated with
 - list all the features available for the site's owner
   (and therefore determine availability of the subset of those for the current
   Plugins API caller)

The endpoint does not require authentication, in the sense that it'll
always respond with 200 OK. However when the token is provided,
a verification lookup is made.

* Remove IO.inspect() call

* Credo

* Aesthetics

* s/send_resp/send_error/

* Call preload just once
This commit is contained in:
hq1 2024-02-21 12:41:56 +01:00 committed by GitHub
parent 23b9032148
commit 6035618213
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 247 additions and 3 deletions

View File

@ -87,6 +87,18 @@ defmodule Plausible.Billing.Feature do
@features
end
@doc """
Lists all the feature short names, e.g. RevenueGoals
"""
defmacro list_short_names() do
@features
|> Enum.map(fn mod ->
Module.split(mod)
|> List.last()
|> String.to_existing_atom()
end)
end
@doc false
defmacro __using__(opts \\ []) do
quote location: :keep do

View File

@ -0,0 +1,35 @@
defmodule Plausible.Plugins.API.Capabilities do
@moduledoc """
Context module for querying API capabilities
"""
require Plausible.Billing.Feature
alias Plausible.Billing.Feature
@spec get(Plug.Conn.t()) :: {:ok, map()}
def get(conn) do
conn = PlausibleWeb.Plugs.AuthorizePluginsAPI.call(conn, send_error?: false)
site = conn.assigns[:authorized_site]
features =
if site do
site = Plausible.Repo.preload(site, :owner)
Feature.list()
|> Enum.map(fn mod ->
result = mod.check_availability(site.owner)
feature = mod |> Module.split() |> List.last()
{feature, result == :ok}
end)
else
Enum.map(Feature.list_short_names(), &{&1, false})
end
{:ok,
%{
authorized: not is_nil(site),
data_domain: site && site.domain,
features: Enum.into(features, %{})
}}
end
end

View File

@ -0,0 +1,24 @@
defmodule PlausibleWeb.Plugins.API.Controllers.Capabilities do
@moduledoc """
Controller for Plugins API Capabilities - doesn't enforce authentication,
serves as a comprehensive health check
"""
use PlausibleWeb, :plugins_api_controller
operation(:index,
summary: "Retrieve Capabilities",
parameters: [],
responses: %{
ok: {"Capabilities response", "application/json", Schemas.Capabilities}
}
)
@spec index(Plug.Conn.t(), %{}) :: Plug.Conn.t()
def index(conn, _params) do
{:ok, capabilities} = API.Capabilities.get(conn)
conn
|> put_view(Views.Capabilities)
|> render("index.json", capabilities: capabilities)
end
end

View File

@ -0,0 +1,39 @@
defmodule PlausibleWeb.Plugins.API.Schemas.Capabilities do
@moduledoc """
OpenAPI schema for Capabilities
"""
use PlausibleWeb, :open_api_schema
require Plausible.Billing.Feature
@features Plausible.Billing.Feature.list_short_names()
@features_schema Enum.reduce(@features, %{}, fn feature, acc ->
Map.put(acc, feature, %Schema{type: :boolean})
end)
OpenApiSpex.schema(%{
title: "Capabilities",
description: "Capabilities object",
type: :object,
required: [:authorized, :data_domain, :features],
properties: %{
authorized: %Schema{type: :boolean},
data_domain: %Schema{type: :string},
features: %Schema{
type: :object,
required: @features,
properties: @features_schema
}
},
example: %{
authorized: true,
data_domain: "example.com",
features: %{
Funnels: false,
Goals: true,
Props: false,
RevenueGoals: false,
StatsAPI: false
}
}
})
end

View File

@ -0,0 +1,10 @@
defmodule PlausibleWeb.Plugins.API.Views.Capabilities do
@moduledoc """
View for rendering Capabilities on the Plugins API
"""
use PlausibleWeb, :plugins_api_view
def render("index.json", %{capabilities: capabilities}) when is_map(capabilities) do
capabilities
end
end

View File

@ -10,10 +10,20 @@ defmodule PlausibleWeb.Plugs.AuthorizePluginsAPI do
def init(opts), do: opts
def call(conn, _opts \\ []) do
def call(conn, opts \\ []) do
send_error? =
Keyword.get(opts, :send_error?, true)
with {:ok, token} <- extract_token(conn),
{:ok, conn} <- authorize(conn, token) do
conn
else
{:unauthorized, conn} ->
if send_error? do
Errors.unauthorized(conn)
else
conn
end
end
end
@ -24,7 +34,7 @@ defmodule PlausibleWeb.Plugs.AuthorizePluginsAPI do
{:ok, Plug.Conn.assign(conn, :authorized_site, token.site)}
{:error, :not_found} ->
Errors.unauthorized(conn)
{:unauthorized, conn}
end
end
@ -37,7 +47,7 @@ defmodule PlausibleWeb.Plugs.AuthorizePluginsAPI do
end
else
_ ->
Errors.unauthorized(conn)
{:unauthorized, conn}
end
end
end

View File

@ -102,6 +102,11 @@ defmodule PlausibleWeb.Router do
get("/swagger-ui", OpenApiSpex.Plug.SwaggerUI, path: "/api/plugins/spec/openapi")
end
scope "/v1/capabilities", PlausibleWeb.Plugins.API.Controllers, assigns: %{plugins_api: true} do
pipe_through([:plugins_api])
get("/", Capabilities, :index)
end
scope "/v1", PlausibleWeb.Plugins.API.Controllers, assigns: %{plugins_api: true} do
pipe_through([:plugins_api, :plugins_api_auth])

View File

@ -0,0 +1,101 @@
defmodule PlausibleWeb.Plugins.API.Controllers.CapabilitiesTest do
use PlausibleWeb.PluginsAPICase, async: true
alias PlausibleWeb.Plugins.API.Schemas
describe "examples" do
test "Capabilities" do
assert_schema(
Schemas.Capabilities.schema().example,
"Capabilities",
spec()
)
end
end
describe "unauthorized" do
test "no token", %{conn: conn} do
resp = get(conn, Routes.plugins_api_capabilities_url(PlausibleWeb.Endpoint, :index))
assert json_response(resp, 200) ==
%{
"authorized" => false,
"data_domain" => nil,
"features" => %{
"Funnels" => false,
"Goals" => false,
"Props" => false,
"RevenueGoals" => false,
"StatsAPI" => false
}
}
end
test "bad token", %{conn: conn} do
resp =
conn
|> put_req_header("content-type", "application/json")
|> authenticate("foo", "bad token")
|> get(Routes.plugins_api_capabilities_url(PlausibleWeb.Endpoint, :index))
assert json_response(resp, 200) ==
%{
"authorized" => false,
"data_domain" => nil,
"features" => %{
"Funnels" => false,
"Goals" => false,
"Props" => false,
"RevenueGoals" => false,
"StatsAPI" => false
}
}
end
end
describe "authorized" do
test "trial", %{conn: conn, site: site, token: token} do
resp =
conn
|> put_req_header("content-type", "application/json")
|> authenticate(site.domain, token)
|> get(Routes.plugins_api_capabilities_url(PlausibleWeb.Endpoint, :index))
assert json_response(resp, 200) ==
%{
"authorized" => true,
"data_domain" => site.domain,
"features" => %{
"Funnels" => true,
"Goals" => true,
"Props" => true,
"RevenueGoals" => true,
"StatsAPI" => true
}
}
end
test "growth", %{conn: conn, site: site, token: token} do
site = Plausible.Repo.preload(site, :owner)
insert(:growth_subscription, user: site.owner)
resp =
conn
|> put_req_header("content-type", "application/json")
|> authenticate(site.domain, token)
|> get(Routes.plugins_api_capabilities_url(PlausibleWeb.Endpoint, :index))
assert json_response(resp, 200) ==
%{
"authorized" => true,
"data_domain" => site.domain,
"features" => %{
"Funnels" => false,
"Goals" => true,
"Props" => false,
"RevenueGoals" => false,
"StatsAPI" => false
}
}
end
end
end

View File

@ -70,6 +70,14 @@ defmodule PlausibleWeb.Plugs.AuthorizePluginsAPITest do
}
end
test "plug optionally doesn't halt when no authorization header is passed" do
conn =
build_conn()
|> AuthorizePluginsAPI.call(send_error?: false)
refute conn.halted
end
test "plug updates last seen timestamp" do
site = insert(:site, domain: "pass.example.com")
{:ok, token, raw} = Tokens.create(site, "Some token")