api-surface #1
2 changed files with 372 additions and 0 deletions
feat(api): endpoint runtime engine — paths, query, bodies, reason, decode
commit
8dd3c99129
202
lib/dexcord/api/endpoint.ex
Normal file
202
lib/dexcord/api/endpoint.ex
Normal 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
|
||||
170
test/dexcord/endpoint_test.exs
Normal file
170
test/dexcord/endpoint_test.exs
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue