api-surface #1

Merged
luna merged 51 commits from api-surface into mistress 2026-07-05 14:43:13 +00:00
2 changed files with 372 additions and 0 deletions
Showing only changes of commit 8dd3c99129 - Show all commits

feat(api): endpoint runtime engine — paths, query, bodies, reason, decode

Luna 2026-07-05 05:07:30 -03:00

202
lib/dexcord/api/endpoint.ex Normal file
View file

@ -0,0 +1,202 @@
defmodule Dexcord.Api.Endpoint do
@moduledoc """
Route-declaration DSL for the Discord REST surface (see `endpoint/4`)
plus the runtime engine behind every generated endpoint function.
A group module `use`s this module and declares its routes with `endpoint/4`;
each declaration compiles to a spec map (the compile-time route table lives
at `__endpoints__/0`) and a documented function that funnels its arguments
through `run/4`. `run/4` builds the path, query string, headers, and body and
hands everything to `Dexcord.Api.request/4`, inheriting its ratelimit and
429-retry guarantees rather than reimplementing them.
## Generated `@spec`s
This layer deliberately does **not** emit per-endpoint `@spec`s. The `@doc`
string carries the route; there is no dialyzer in this repo (no `dialyxir`
dep), so specs on the ~180 generated heads would add macro complexity with no
verification leverage.
"""
alias Dexcord.Snowflake
@type snowflake_arg :: Dexcord.Snowflake.t() | String.t() | struct()
@type body_arg :: keyword() | map() | struct() | [map() | struct()]
@doc false
def run(spec, path_args, body, opts) do
with {:ok, path} <- build_path(spec, path_args) do
full_path = path <> build_query(spec.query, opts)
encoded_body = prepare_body(spec, body, opts)
req_opts = reason_opts(spec, opts)
Dexcord.Api.request(spec.method, full_path, encoded_body, req_opts)
|> decode_response(spec.returns)
end
end
# --- path ---
@doc false
def build_path(spec, path_args) do
params = for {:param, p} <- spec.segments, do: p
values =
Enum.zip(params, path_args)
|> Map.new(fn {p, arg} -> {p, encode_segment(p, arg)} end)
case Enum.find(values, fn {_p, v} -> v == :error end) do
{p, :error} ->
{:error,
%Dexcord.Api.Error{
status: nil,
code: nil,
message: "invalid value for path parameter :#{p}",
errors: nil
}}
nil ->
segments =
Enum.map(spec.segments, fn
{:param, p} -> values[p]
literal -> literal
end)
{:ok, "/" <> Enum.join(segments, "/")}
end
end
# *_id params take snowflake-ish args; everything else is a URL-encoded string.
defp encode_segment(param, arg) do
if String.ends_with?(Atom.to_string(param), "_id") do
case Snowflake.cast(arg) do
{:ok, int} -> Integer.to_string(int)
:error -> :error
end
else
case arg do
arg when is_binary(arg) -> URI.encode(arg, &URI.char_unreserved?/1)
arg when is_integer(arg) -> Integer.to_string(arg)
_ -> :error
end
end
end
# --- query ---
@doc false
def build_query(allowed, opts) do
pairs =
for key <- allowed,
{:ok, value} <- [Keyword.fetch(opts, key)],
value != nil,
do: {key, query_value(value)}
case pairs do
[] -> ""
pairs -> "?" <> URI.encode_query(pairs)
end
end
defp query_value(%{id: id}) when is_integer(id), do: Integer.to_string(id)
defp query_value(true), do: "true"
defp query_value(false), do: "false"
defp query_value(v) when is_integer(v), do: Integer.to_string(v)
defp query_value(v) when is_binary(v), do: v
defp query_value(v), do: to_string(v)
# --- body ---
@doc false
def prepare_body(%{body: false}, _body, _opts), do: nil
def prepare_body(spec, body, opts) do
payload = encode_body(body, spec)
case {spec.files, Keyword.get(opts, :files)} do
{true, [_ | _] = files} ->
normalized = Enum.map(files, &normalize_file/1)
{:multipart, attach_ids(payload, normalized),
Enum.map(normalized, &Map.delete(&1, :description))}
_ ->
payload
end
end
@doc false
def encode_body(nil, _spec), do: nil
def encode_body(body, %{binary_wrap: key}) when is_binary(body) and key != nil,
do: %{Atom.to_string(key) => body}
def encode_body(body, _spec) when is_list(body) do
if Keyword.keyword?(body) do
# Keyword contract (AC2.9): absent key = omitted; explicit nil = JSON null.
Map.new(body, fn {k, v} -> {Atom.to_string(k), encode_body_value(v)} end)
else
# top-level array bodies (e.g. bulk overwrite)
Enum.map(body, &encode_body(&1, %{binary_wrap: nil}))
end
end
def encode_body(%_{} = struct, _spec), do: struct.__struct__.to_map(struct)
def encode_body(body, _spec) when is_map(body), do: body
defp encode_body_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
defp encode_body_value(%_{} = struct), do: struct.__struct__.to_map(struct)
defp encode_body_value(list) when is_list(list), do: Enum.map(list, &encode_body_value/1)
defp encode_body_value(v), do: v
defp normalize_file(%{filename: f, data: d} = file) when is_binary(f) and is_binary(d) do
%{
filename: f,
data: d,
content_type: Map.get(file, :content_type, "application/octet-stream"),
description: Map.get(file, :description)
}
end
# payload_json's attachments array references files[n] by id == n.
defp attach_ids(payload, files) do
declared =
files
|> Enum.with_index()
|> Enum.map(fn {file, n} ->
%{"id" => n, "filename" => file.filename}
|> then(fn m ->
if file.description, do: Map.put(m, "description", file.description), else: m
end)
end)
Map.update(payload || %{}, "attachments", declared, fn existing -> existing ++ declared end)
end
# --- reason header ---
defp reason_opts(%{reason: true}, opts) do
case Keyword.get(opts, :reason) do
nil -> []
reason -> [audit_log_reason: URI.encode(reason, &URI.char_unreserved?/1)]
end
end
defp reason_opts(_spec, _opts), do: []
# --- response decode ---
@doc false
def decode_response({:error, _} = err, _returns), do: err
def decode_response({:ok, nil}, _returns), do: {:ok, nil}
def decode_response({:ok, {:raw, _}} = raw, _returns), do: raw
def decode_response(ok, :raw), do: ok
def decode_response({:ok, list}, [mod]) when is_list(list),
do: {:ok, Enum.map(list, &mod.from_map/1)}
def decode_response({:ok, map}, mod) when is_atom(mod) and mod != nil and is_map(map),
do: {:ok, mod.from_map(map)}
def decode_response(ok, _returns), do: ok
end

View file

@ -0,0 +1,170 @@
defmodule Dexcord.Api.EndpointTest do
@moduledoc """
Pure unit tests for the endpoint runtime engine helpers path/query/body
building and response decoding. No HTTP: these exercise the functions in
isolation with hand-built spec maps.
"""
use ExUnit.Case, async: true
alias Dexcord.Api.Endpoint
alias Dexcord.Api.Error
# A spec fragment for /channels/:channel_id/messages.
defp messages_spec do
%{segments: ["channels", {:param, :channel_id}, "messages"]}
end
describe "build_path/2 (AC1.5)" do
test "accepts integer, decimal string, and struct-with-id, all identical" do
spec = messages_spec()
assert Endpoint.build_path(spec, [123]) == {:ok, "/channels/123/messages"}
assert Endpoint.build_path(spec, ["123"]) == {:ok, "/channels/123/messages"}
assert Endpoint.build_path(spec, [%Dexcord.Test.Widget{id: 123}]) ==
{:ok, "/channels/123/messages"}
end
test "a non-numeric snowflake returns an Error naming the param, no HTTP" do
assert {:error, %Error{status: nil, message: message}} =
Endpoint.build_path(messages_spec(), ["12x"])
assert message =~ ":channel_id"
end
test "non-id params are URL-encoded strings" do
spec = %{
segments: [
"channels",
{:param, :channel_id},
"reactions",
{:param, :emoji},
"@me"
]
}
assert {:ok, path} = Endpoint.build_path(spec, [1, "🔥"])
assert path == "/channels/1/reactions/%F0%9F%94%A5/@me"
assert {:ok, colon_path} = Endpoint.build_path(spec, [1, "name:123"])
assert colon_path == "/channels/1/reactions/name%3A123/@me"
end
end
describe "build_query/2 (AC1.2)" do
test "encodes only declared, present, non-nil keys" do
assert Endpoint.build_query([:limit, :around], limit: 50) == "?limit=50"
end
test "no matching opts yields an empty string" do
assert Endpoint.build_query([:limit, :around], []) == ""
assert Endpoint.build_query([:limit], limit: nil) == ""
end
test "struct-with-id values collapse to the id" do
assert Endpoint.build_query([:around], around: %Dexcord.Test.Widget{id: 99}) ==
"?around=99"
end
test "unknown opt keys are ignored" do
assert Endpoint.build_query([:limit], limit: 5, mystery: "x") == "?limit=5"
end
end
describe "encode_body/2 (AC2.9)" do
test "keyword: absent key omitted, explicit nil kept as JSON null" do
spec = %{binary_wrap: nil}
assert Endpoint.encode_body([content: "hi", nonce: nil], spec) ==
%{"content" => "hi", "nonce" => nil}
refute Map.has_key?(Endpoint.encode_body([content: "hi"], spec), "nonce")
end
test "nested structs in a keyword value encode via to_map" do
spec = %{binary_wrap: nil}
result = Endpoint.encode_body([embeds: [%Dexcord.Test.Gadget{id: 1, name: "t"}]], spec)
assert %{"embeds" => [embed]} = result
assert embed["name"] == "t"
end
test "binary body with binary_wrap wraps to the configured key" do
assert Endpoint.encode_body("hi", %{binary_wrap: :content}) == %{"content" => "hi"}
end
test "a top-level (non-keyword) list maps each struct element" do
spec = %{binary_wrap: nil}
result = Endpoint.encode_body([%Dexcord.Test.Gadget{id: 1, name: "a"}], spec)
assert [%{"name" => "a"}] = result
end
test "nil body encodes to nil" do
assert Endpoint.encode_body(nil, %{binary_wrap: nil}) == nil
end
end
describe "prepare_body/3 with files (AC1.4)" do
defp files_spec do
%{body: true, files: true, binary_wrap: nil}
end
test "returns a multipart tuple with attachments injected and description propagated" do
files = [%{filename: "a.png", data: <<1>>, description: "alt"}]
assert {:multipart, payload, [file]} =
Endpoint.prepare_body(files_spec(), [content: "hi"], files: files)
assert payload["content"] == "hi"
assert payload["attachments"] == [
%{"id" => 0, "filename" => "a.png", "description" => "alt"}
]
assert file.filename == "a.png"
assert file.content_type == "application/octet-stream"
refute Map.has_key?(file, :description)
end
test "without :files in opts returns a plain map" do
assert Endpoint.prepare_body(files_spec(), [content: "hi"], []) == %{"content" => "hi"}
end
test "a body:false spec always yields nil" do
assert Endpoint.prepare_body(%{body: false}, [content: "x"], []) == nil
end
end
describe "decode_response/2 (AC1.6 + passthrough)" do
test "decodes a single map to the returns struct" do
assert {:ok, %Dexcord.User{id: 1}} =
Endpoint.decode_response({:ok, %{"id" => "1"}}, Dexcord.User)
end
test "decodes a list to a list of structs" do
assert {:ok, [%Dexcord.User{id: 1}, %Dexcord.User{id: 2}]} =
Endpoint.decode_response(
{:ok, [%{"id" => "1"}, %{"id" => "2"}]},
[Dexcord.User]
)
end
test "an error tuple passes through untouched" do
err = {:error, %Error{status: 403, message: "no"}}
assert Endpoint.decode_response(err, Dexcord.User) == err
end
test "a raw body passes through" do
assert Endpoint.decode_response({:ok, {:raw, "x"}}, Dexcord.User) == {:ok, {:raw, "x"}}
end
test "a nil body passes through" do
assert Endpoint.decode_response({:ok, nil}, Dexcord.User) == {:ok, nil}
end
test "returns: :raw leaves the decoded map alone" do
assert Endpoint.decode_response({:ok, %{"id" => "1"}}, :raw) == {:ok, %{"id" => "1"}}
end
end
end