api-surface #1

Merged
luna merged 51 commits from api-surface into mistress 2026-07-05 14:43:13 +00:00
4 changed files with 316 additions and 1 deletions
Showing only changes of commit 45aa60de3d - Show all commits

feat(api): endpoint/4 declaration macro with compiled route table

Luna 2026-07-05 05:10:19 -03:00

View file

@ -6,6 +6,8 @@
field: 3,
hydrate: 2,
discord_struct: 1,
include_fields: 1
include_fields: 1,
endpoint: 3,
endpoint: 4
]
]

View file

@ -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

View 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

View 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