api-surface #1
4 changed files with 316 additions and 1 deletions
feat(api): endpoint/4 declaration macro with compiled route table
commit
45aa60de3d
|
|
@ -6,6 +6,8 @@
|
|||
field: 3,
|
||||
hydrate: 2,
|
||||
discord_struct: 1,
|
||||
include_fields: 1
|
||||
include_fields: 1,
|
||||
endpoint: 3,
|
||||
endpoint: 4
|
||||
]
|
||||
]
|
||||
|
|
|
|||
|
|
@ -23,6 +23,122 @@ defmodule Dexcord.Api.Endpoint do
|
|||
@type snowflake_arg :: Dexcord.Snowflake.t() | String.t() | struct()
|
||||
@type body_arg :: keyword() | map() | struct() | [map() | struct()]
|
||||
|
||||
# --- macro ---
|
||||
|
||||
@doc false
|
||||
defmacro __using__(_opts) do
|
||||
quote do
|
||||
import Dexcord.Api.Endpoint, only: [endpoint: 3, endpoint: 4]
|
||||
Module.register_attribute(__MODULE__, :dexcord_endpoints, accumulate: true)
|
||||
@before_compile Dexcord.Api.Endpoint
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
defmacro __before_compile__(env) do
|
||||
endpoints =
|
||||
env.module |> Module.get_attribute(:dexcord_endpoints) |> Enum.reverse()
|
||||
|
||||
quote do
|
||||
@doc false
|
||||
def __endpoints__, do: unquote(Macro.escape(endpoints))
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Declares a REST endpoint, generating a documented function plus a spec entry
|
||||
in the module's `__endpoints__/0` route table.
|
||||
|
||||
`name` is the generated function name, `method` the HTTP verb atom, and `path`
|
||||
the route with `:param` placeholders (e.g. `"/channels/:channel_id/messages"`).
|
||||
Each placeholder becomes a leading positional argument; `body: true` adds a
|
||||
`body` argument before the trailing `opts` keyword list.
|
||||
|
||||
## Options
|
||||
|
||||
* `:query` - allowed query-parameter keys (atoms), read from `opts`
|
||||
* `:body` - `true` if the endpoint takes a body argument
|
||||
* `:binary_wrap` - key (atom) a binary body is wrapped under
|
||||
* `:files` - `true` if `opts[:files]` triggers multipart encoding
|
||||
* `:reason` - `true` if `opts[:reason]` sends `X-Audit-Log-Reason`
|
||||
* `:returns` - `module | [module] | nil | :raw` decode target (default `:raw`)
|
||||
* `:docs` - a Discord docs URL appended to the generated `@doc`
|
||||
"""
|
||||
defmacro endpoint(name, method, path, opts \\ []) do
|
||||
segments = parse_path(path)
|
||||
params = for {:param, p} <- segments, do: p
|
||||
returns_ast = Keyword.get(opts, :returns, :raw)
|
||||
|
||||
spec = %{
|
||||
name: name,
|
||||
method: method,
|
||||
path: path,
|
||||
segments: segments,
|
||||
query: Keyword.get(opts, :query, []),
|
||||
body: Keyword.get(opts, :body, false),
|
||||
binary_wrap: Keyword.get(opts, :binary_wrap),
|
||||
files: Keyword.get(opts, :files, false),
|
||||
reason: Keyword.get(opts, :reason, false)
|
||||
}
|
||||
|
||||
param_vars = Enum.map(params, &Macro.var(&1, __MODULE__))
|
||||
|
||||
doc_string =
|
||||
"`#{method |> to_string() |> String.upcase()} #{path}`" <>
|
||||
case Keyword.get(opts, :docs) do
|
||||
nil -> ""
|
||||
url -> "\n\nDiscord docs: #{url}"
|
||||
end
|
||||
|
||||
if spec.body do
|
||||
quote do
|
||||
@dexcord_endpoints Map.put(
|
||||
unquote(Macro.escape(spec)),
|
||||
:returns,
|
||||
unquote(returns_ast)
|
||||
)
|
||||
@doc unquote(doc_string)
|
||||
def unquote(name)(unquote_splicing(param_vars), body, opts \\ []) do
|
||||
Dexcord.Api.Endpoint.run(
|
||||
Map.put(unquote(Macro.escape(spec)), :returns, unquote(returns_ast)),
|
||||
[unquote_splicing(param_vars)],
|
||||
body,
|
||||
opts
|
||||
)
|
||||
end
|
||||
end
|
||||
else
|
||||
quote do
|
||||
@dexcord_endpoints Map.put(
|
||||
unquote(Macro.escape(spec)),
|
||||
:returns,
|
||||
unquote(returns_ast)
|
||||
)
|
||||
@doc unquote(doc_string)
|
||||
def unquote(name)(unquote_splicing(param_vars), opts \\ []) do
|
||||
Dexcord.Api.Endpoint.run(
|
||||
Map.put(unquote(Macro.escape(spec)), :returns, unquote(returns_ast)),
|
||||
[unquote_splicing(param_vars)],
|
||||
nil,
|
||||
opts
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
def parse_path(path) do
|
||||
path
|
||||
|> String.split("/", trim: true)
|
||||
|> Enum.map(fn
|
||||
":" <> param -> {:param, String.to_atom(param)}
|
||||
literal -> literal
|
||||
end)
|
||||
end
|
||||
|
||||
# --- runtime ---
|
||||
|
||||
@doc false
|
||||
def run(spec, path_args, body, opts) do
|
||||
with {:ok, path} <- build_path(spec, path_args) do
|
||||
|
|
|
|||
175
test/dexcord/endpoint_macro_test.exs
Normal file
175
test/dexcord/endpoint_macro_test.exs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
defmodule Dexcord.Api.EndpointMacroTest do
|
||||
@moduledoc """
|
||||
Round-trip tests for the `endpoint/4` macro: the generated functions issue the
|
||||
right method/path/query/headers/body through the real `Dexcord.Api` pipeline
|
||||
against the scripted `Dexcord.FakeRest` server, and decode responses per their
|
||||
declared `returns:`.
|
||||
"""
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Dexcord.Api.Error
|
||||
alias Dexcord.Api.Ratelimit
|
||||
alias Dexcord.FakeRest
|
||||
alias Dexcord.Test.Endpoints
|
||||
alias Dexcord.Test.Widget
|
||||
|
||||
@token "test.token.value"
|
||||
|
||||
setup do
|
||||
Dexcord.EnvSandbox.sandbox_env()
|
||||
Dexcord.Config.put(%{token: @token, handler: nil, intents: 0})
|
||||
|
||||
start_supervised!({Finch, name: Dexcord.Finch})
|
||||
start_supervised!(Ratelimit)
|
||||
start_supervised!(FakeRest)
|
||||
|
||||
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
|
||||
FakeRest.subscribe(self())
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "AC1.1: method + interpolated path + typed decode" do
|
||||
test "get_widget/1 issues GET and decodes the returns struct" do
|
||||
FakeRest.stub(:get, "/channels/1/widget", FakeRest.resp(200, body: ~s({"id":"77"})))
|
||||
|
||||
assert {:ok, %Widget{id: 77}} = Endpoints.get_widget(1)
|
||||
|
||||
assert_receive {:rest_hit, info}
|
||||
assert info.method == "GET"
|
||||
assert info.path == "/channels/1/widget"
|
||||
end
|
||||
end
|
||||
|
||||
describe "AC1.2: query params" do
|
||||
test "declared query keys are URL-encoded when present" do
|
||||
FakeRest.stub(:get, "/channels/1/widget", FakeRest.resp(200, body: ~s({"id":"1"})))
|
||||
|
||||
assert {:ok, %Widget{}} = Endpoints.get_widget(1, limit: 5, around: 99)
|
||||
|
||||
assert_receive {:rest_hit, info}
|
||||
assert URI.decode_query(info.query_string) == %{"limit" => "5", "around" => "99"}
|
||||
end
|
||||
|
||||
test "no query opts sends an empty query string" do
|
||||
FakeRest.stub(:get, "/channels/1/widget", FakeRest.resp(200, body: ~s({"id":"1"})))
|
||||
|
||||
assert {:ok, %Widget{}} = Endpoints.get_widget(1)
|
||||
|
||||
assert_receive {:rest_hit, info}
|
||||
assert info.query_string == ""
|
||||
end
|
||||
end
|
||||
|
||||
describe "AC1.3: reason header" do
|
||||
test "reason: endpoints send a URL-encoded X-Audit-Log-Reason" do
|
||||
FakeRest.stub(:delete, "/channels/1/widget/2", FakeRest.resp(204))
|
||||
|
||||
assert {:ok, nil} = Endpoints.delete_widget(1, 2, reason: "spam & bad")
|
||||
|
||||
assert_receive {:rest_hit, info}
|
||||
assert Map.new(info.headers)["x-audit-log-reason"] == "spam%20%26%20bad"
|
||||
end
|
||||
|
||||
test "without :reason no header is sent" do
|
||||
FakeRest.stub(:delete, "/channels/1/widget/2", FakeRest.resp(204))
|
||||
|
||||
assert {:ok, nil} = Endpoints.delete_widget(1, 2)
|
||||
|
||||
assert_receive {:rest_hit, info}
|
||||
refute Map.has_key?(Map.new(info.headers), "x-audit-log-reason")
|
||||
end
|
||||
end
|
||||
|
||||
describe "AC1.6: error decoding" do
|
||||
test "a 4xx decodes to an Error struct with status/code/message" do
|
||||
body = ~s({"code":50013,"message":"Missing Permissions"})
|
||||
FakeRest.stub(:get, "/channels/1/widget", FakeRest.resp(403, body: body))
|
||||
|
||||
assert {:error, %Error{status: 403, code: 50013, message: "Missing Permissions"}} =
|
||||
Endpoints.get_widget(1)
|
||||
end
|
||||
end
|
||||
|
||||
describe "multipart + binary wrap" do
|
||||
test "files: opts encode multipart with payload_json carrying attachments" do
|
||||
FakeRest.stub(:post, "/channels/1/widget", FakeRest.resp(200, body: ~s({"id":"1"})))
|
||||
|
||||
assert {:ok, %Widget{}} =
|
||||
Endpoints.create_widget(1, [name: "w"], files: [%{filename: "a.png", data: <<1>>}])
|
||||
|
||||
assert_receive {:rest_hit, info}
|
||||
"multipart/form-data; boundary=" <> boundary = Map.new(info.headers)["content-type"]
|
||||
|
||||
payload = payload_json(info.body, boundary)
|
||||
assert payload["name"] == "w"
|
||||
assert payload["attachments"] == [%{"id" => 0, "filename" => "a.png"}]
|
||||
end
|
||||
|
||||
test "a binary body wraps to the declared key" do
|
||||
FakeRest.stub(:post, "/channels/1/widget", FakeRest.resp(200, body: ~s({"id":"1"})))
|
||||
|
||||
assert {:ok, %Widget{}} = Endpoints.create_widget(1, "hello")
|
||||
|
||||
assert_receive {:rest_hit, info}
|
||||
assert JSON.decode!(info.body) == %{"name" => "hello"}
|
||||
end
|
||||
end
|
||||
|
||||
describe "list returns and default :raw" do
|
||||
test "a [Module] return decodes to a list of structs" do
|
||||
FakeRest.stub(
|
||||
:get,
|
||||
"/channels/1/widgets",
|
||||
FakeRest.resp(200, body: ~s([{"id":"1"},{"id":"2"}]))
|
||||
)
|
||||
|
||||
assert {:ok, [%Widget{id: 1}, %Widget{id: 2}]} = Endpoints.list_widgets(1)
|
||||
end
|
||||
|
||||
test "an endpoint without returns: yields the raw decoded map" do
|
||||
FakeRest.stub(:get, "/things/5", FakeRest.resp(200, body: ~s({"id":"5","k":"v"})))
|
||||
|
||||
assert {:ok, %{"id" => "5", "k" => "v"}} = Endpoints.raw_thing(5)
|
||||
end
|
||||
end
|
||||
|
||||
describe "__endpoints__/0 route table" do
|
||||
test "lists every declaration in order with parsed segments" do
|
||||
specs = Endpoints.__endpoints__()
|
||||
|
||||
assert Enum.map(specs, & &1.name) == [
|
||||
:get_widget,
|
||||
:create_widget,
|
||||
:delete_widget,
|
||||
:list_widgets,
|
||||
:raw_thing
|
||||
]
|
||||
|
||||
get_widget = Enum.find(specs, &(&1.name == :get_widget))
|
||||
assert get_widget.method == :get
|
||||
assert get_widget.segments == ["channels", {:param, :channel_id}, "widget"]
|
||||
assert get_widget.query == [:limit, :around]
|
||||
assert get_widget.returns == Dexcord.Test.Widget
|
||||
|
||||
list_widgets = Enum.find(specs, &(&1.name == :list_widgets))
|
||||
assert list_widgets.returns == [Dexcord.Test.Widget]
|
||||
|
||||
raw_thing = Enum.find(specs, &(&1.name == :raw_thing))
|
||||
assert raw_thing.returns == :raw
|
||||
end
|
||||
end
|
||||
|
||||
# --- helpers ------------------------------------------------------------
|
||||
|
||||
defp payload_json(body, boundary) do
|
||||
body
|
||||
|> String.split("--" <> boundary)
|
||||
|> Enum.find_value(fn seg ->
|
||||
if seg =~ ~s(name="payload_json") do
|
||||
[_headers, rest] = String.split(seg, "\r\n\r\n", parts: 2)
|
||||
rest |> String.replace_suffix("\r\n", "") |> JSON.decode!()
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
22
test/support/endpoint_fixtures.ex
Normal file
22
test/support/endpoint_fixtures.ex
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
defmodule Dexcord.Test.Endpoints do
|
||||
@moduledoc false
|
||||
use Dexcord.Api.Endpoint
|
||||
|
||||
endpoint :get_widget, :get, "/channels/:channel_id/widget",
|
||||
query: [:limit, :around],
|
||||
returns: Dexcord.Test.Widget
|
||||
|
||||
endpoint :create_widget, :post, "/channels/:channel_id/widget",
|
||||
body: true,
|
||||
binary_wrap: :name,
|
||||
files: true,
|
||||
returns: Dexcord.Test.Widget
|
||||
|
||||
endpoint :delete_widget, :delete, "/channels/:channel_id/widget/:widget_id",
|
||||
reason: true,
|
||||
returns: nil
|
||||
|
||||
endpoint :list_widgets, :get, "/channels/:channel_id/widgets", returns: [Dexcord.Test.Widget]
|
||||
|
||||
endpoint :raw_thing, :get, "/things/:thing_id"
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue