mirror of
https://github.com/plausible/analytics.git
synced 2025-01-03 15:17:58 +03:00
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:
parent
23b9032148
commit
6035618213
@ -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
|
||||
|
35
lib/plausible/plugins/api/capabilities.ex
Normal file
35
lib/plausible/plugins/api/capabilities.ex
Normal 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
|
24
lib/plausible_web/plugins/api/controllers/capabilities.ex
Normal file
24
lib/plausible_web/plugins/api/controllers/capabilities.ex
Normal 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
|
39
lib/plausible_web/plugins/api/schemas/capabilities.ex
Normal file
39
lib/plausible_web/plugins/api/schemas/capabilities.ex
Normal 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
|
10
lib/plausible_web/plugins/api/views/capabilities.ex
Normal file
10
lib/plausible_web/plugins/api/views/capabilities.ex
Normal 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
|
@ -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
|
||||
|
@ -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])
|
||||
|
||||
|
101
test/plausible_web/plugins/api/controllers/capabilities_test.exs
Normal file
101
test/plausible_web/plugins/api/controllers/capabilities_test.exs
Normal 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
|
@ -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")
|
||||
|
Loading…
Reference in New Issue
Block a user