api-surface #1

Merged
luna merged 51 commits from api-surface into mistress 2026-07-05 14:43:13 +00:00
15 changed files with 387 additions and 236 deletions
Showing only changes of commit 234dbd3dea - Show all commits

refactor(api): existing endpoints reborn as DSL declarations behind a generated facade

Redeclare the full existing endpoint surface with the Dexcord.Api.Endpoint
DSL across six group modules (Channels, Messages, Users, Interactions,
Commands, Misc) and delete the old named endpoint functions plus their
private helpers (message_query/maybe_param/maybe_put) from Dexcord.Api,
keeping request/4 and the rest of the request pipeline untouched (AC1.7).

Dexcord.Api re-exports every group endpoint head as a generated defdelegate
driven by module_info(:exports), so Dexcord.Api.<endpoint> keeps working for
all 9 internal call sites and external callers. create_dm is registered by
hand (spec + snowflake sugar) because the generated create_dm(body, opts \\ [])
head would collide with create_dm/1.

Endpoints are now typed: get_current_user -> Dexcord.User, create/edit_message
-> Dexcord.Message, get_channel/start_thread -> Dexcord.Channel,
get_current_application -> Dexcord.ApplicationInfo, bulk_overwrite_* ->
[Dexcord.ApplicationCommand], etc. get_gateway_bot and
create_interaction_response stay :raw. Patch registrar to match
%Dexcord.ApplicationInfo{} and widen Config.put_application_id/1 to accept
integer ids.

Behavior changes (documented in tests):
- get_channel_messages/2 no longer collapses multiple anchors with a
  before>after>around precedence; it forwards every declared query key, so
  callers pass exactly one anchor (the /3 locator arity still enforces this).
- start_thread_with_message extra fields now travel in the body argument
  rather than a trailing opts list.
- *_id path params are validated via Snowflake.cast/1; integration-test
  fixtures updated from non-numeric placeholders to numeric snowflake ids.

Add test/dexcord/api_facade_test.exs asserting the facade cannot silently drop
any group endpoint export.

Claude-Session: https://claude.ai/code/session_01T4vgXtQLs2wfNwn6B1m4hm
Luna 2026-07-05 05:32:20 -03:00

View file

@ -3,7 +3,11 @@ defmodule Dexcord.Api do
REST client over Finch, governed by `Dexcord.Api.Ratelimit`.
`request/4` is the generic escape hatch and the only place that talks to the
network; the ~16 typed endpoints below are thin wrappers over it. Every request
network; the typed endpoint surface is declared with the
`Dexcord.Api.Endpoint` DSL across `Dexcord.Api.Channels`,
`Dexcord.Api.Messages`, `Dexcord.Api.Users`, `Dexcord.Api.Interactions`,
`Dexcord.Api.Commands`, and `Dexcord.Api.Misc`, then re-exported here as
generated delegates (`Dexcord.Api.get_channel/1` and friends). Every request
flows through the limiter: acquire a token (sleeping in the caller's process
while `{:wait, ms}`), issue the request, feed the response headers back for
bucket learning, and on a 429 honor `retry_after` / the global scope and retry
@ -331,185 +335,32 @@ defmodule Dexcord.Api do
end
end
# Builds the query string for `get_channel_messages/2`: `:limit` plus at most
# one anchor (`:before` > `:after` > `:around`), or `""` when neither is given.
defp message_query(opts) do
anchor =
Enum.find_value([:before, :after, :around], fn k ->
case Keyword.get(opts, k) do
nil -> nil
id -> {k, id}
end
end)
# --- Generated endpoint facade -----------------------------------------
#
# The typed endpoint surface lives in the group modules below (declared with
# the `Dexcord.Api.Endpoint` DSL). Each public endpoint head is re-exported
# here as a `defdelegate`, so `Dexcord.Api.<endpoint>` keeps working for every
# internal and external caller. `module_info(:exports)` is read at compile
# time, which establishes the compile-order dependency on the groups; a
# function's default-arity heads appear as separate exports, so (e.g.) both
# `create_message/2` and `create_message/3` receive delegates.
params =
[]
|> maybe_param(:limit, Keyword.get(opts, :limit))
|> maybe_param(anchor && elem(anchor, 0), anchor && elem(anchor, 1))
@endpoint_groups [
Dexcord.Api.Channels,
Dexcord.Api.Messages,
Dexcord.Api.Users,
Dexcord.Api.Interactions,
Dexcord.Api.Commands,
Dexcord.Api.Misc
]
case params do
[] -> ""
_ -> "?" <> URI.encode_query(params)
end
@doc false
def __endpoint_groups__, do: @endpoint_groups
for group <- @endpoint_groups,
{name, arity} <- group.module_info(:exports),
name not in [:module_info, :__info__, :__endpoints__] do
args = Macro.generate_arguments(arity, __MODULE__)
defdelegate unquote(name)(unquote_splicing(args)), to: group
end
defp maybe_param(params, _key, nil), do: params
defp maybe_param(params, key, value), do: [{key, to_string(value)} | params]
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
# --- Typed endpoints ----------------------------------------------------
@doc "GET /gateway/bot - recommended gateway url + shard/session-start info."
@spec get_gateway_bot() :: {:ok, map()} | {:error, Error.t()}
def get_gateway_bot, do: request(:get, "/gateway/bot")
@doc "GET /users/@me - the current bot user."
@spec get_current_user() :: {:ok, map()} | {:error, Error.t()}
def get_current_user, do: request(:get, "/users/@me")
@doc "GET /oauth2/applications/@me - the current application object."
@spec get_current_application() :: {:ok, map()} | {:error, Error.t()}
def get_current_application, do: request(:get, "/oauth2/applications/@me")
@doc "GET /users/:user_id - a user."
@spec get_user(id()) :: {:ok, map()} | {:error, Error.t()}
def get_user(user_id), do: request(:get, "/users/#{user_id}")
@doc "GET /channels/:channel_id - a channel."
@spec get_channel(id()) :: {:ok, map()} | {:error, Error.t()}
def get_channel(channel_id), do: request(:get, "/channels/#{channel_id}")
@doc "GET /guilds/:guild_id - a guild."
@spec get_guild(id()) :: {:ok, map()} | {:error, Error.t()}
def get_guild(guild_id), do: request(:get, "/guilds/#{guild_id}")
@doc "POST /users/@me/channels - open (or fetch) a DM channel with a user."
@spec create_dm(id()) :: {:ok, map()} | {:error, Error.t()}
def create_dm(user_id) do
request(:post, "/users/@me/channels", %{"recipient_id" => to_string(user_id)})
end
@doc """
GET /channels/:channel_id/messages - a channel's messages, newest first.
`opts` is a keyword list; `:limit` caps the count and at most one anchor of
`:before` / `:after` / `:around` (that precedence) selects the window. All are
encoded into the query string.
"""
@spec get_channel_messages(id(), keyword()) :: {:ok, [map()]} | {:error, Error.t()}
def get_channel_messages(channel_id, opts \\ []) do
request(:get, "/channels/#{channel_id}/messages" <> message_query(opts))
end
@doc """
GET /channels/:channel_id/messages - `Nostrum.Api`-compatible arity.
`locator` is `{:before, id}` / `{:after, id}` / `{:around, id}` or `{}` for no
anchor; it is normalized onto the keyword form of `get_channel_messages/2`.
"""
@spec get_channel_messages(id(), pos_integer(), {atom(), id()} | {}) ::
{:ok, [map()]} | {:error, Error.t()}
def get_channel_messages(channel_id, limit, locator) do
opts =
case locator do
{a, id} when a in [:before, :after, :around] -> [{:limit, limit}, {a, id}]
{} -> [limit: limit]
end
get_channel_messages(channel_id, opts)
end
@doc "POST /channels/:channel_id/messages - send a message."
@spec create_message(id(), String.t() | map()) :: {:ok, map()} | {:error, Error.t()}
def create_message(channel_id, content) when is_binary(content) do
create_message(channel_id, %{"content" => content})
end
def create_message(channel_id, %{} = params) do
request(:post, "/channels/#{channel_id}/messages", params)
end
@doc "PATCH /channels/:channel_id/messages/:message_id - edit a message."
@spec edit_message(id(), id(), String.t() | map()) :: {:ok, map()} | {:error, Error.t()}
def edit_message(channel_id, message_id, content) when is_binary(content) do
edit_message(channel_id, message_id, %{"content" => content})
end
def edit_message(channel_id, message_id, %{} = params) do
request(:patch, "/channels/#{channel_id}/messages/#{message_id}", params)
end
@doc "DELETE /channels/:channel_id/messages/:message_id - delete a message."
@spec delete_message(id(), id()) :: {:ok, nil} | {:error, Error.t()}
def delete_message(channel_id, message_id) do
request(:delete, "/channels/#{channel_id}/messages/#{message_id}")
end
@doc """
PUT /channels/:channel_id/messages/:message_id/reactions/:emoji/@me - react as
the bot. `emoji` is a unicode emoji or a `name:id` custom emoji; it is
URL-encoded for you.
"""
@spec create_reaction(id(), id(), String.t()) :: {:ok, nil} | {:error, Error.t()}
def create_reaction(channel_id, message_id, emoji) do
encoded = URI.encode(emoji, &URI.char_unreserved?/1)
request(:put, "/channels/#{channel_id}/messages/#{message_id}/reactions/#{encoded}/@me")
end
@doc """
POST /channels/:channel_id/messages/:message_id/threads - start a thread from
an existing message.
`opts` may carry `:auto_archive_duration` and `:rate_limit_per_user`; each is
omitted from the body when `nil`.
"""
@spec start_thread_with_message(id(), id(), String.t(), keyword()) ::
{:ok, map()} | {:error, Error.t()}
def start_thread_with_message(channel_id, message_id, name, opts \\ []) when is_binary(name) do
body =
%{"name" => name}
|> maybe_put("auto_archive_duration", Keyword.get(opts, :auto_archive_duration))
|> maybe_put("rate_limit_per_user", Keyword.get(opts, :rate_limit_per_user))
request(:post, "/channels/#{channel_id}/messages/#{message_id}/threads", body)
end
@doc "POST /interactions/:interaction_id/:token/callback - respond to an interaction."
@spec create_interaction_response(id(), String.t(), map()) ::
{:ok, nil} | {:ok, map()} | {:error, Error.t()}
def create_interaction_response(interaction_id, token, response) do
request(:post, "/interactions/#{interaction_id}/#{token}/callback", response)
end
@doc "PATCH /webhooks/:application_id/:token/messages/@original - edit the original response."
@spec edit_original_interaction_response(id(), String.t(), map()) ::
{:ok, map()} | {:error, Error.t()}
def edit_original_interaction_response(application_id, token, body) do
request(:patch, "/webhooks/#{application_id}/#{token}/messages/@original", body)
end
@doc "POST /webhooks/:application_id/:token - send an interaction followup message."
@spec create_followup_message(id(), String.t(), map()) ::
{:ok, map()} | {:error, Error.t()}
def create_followup_message(application_id, token, body) do
request(:post, "/webhooks/#{application_id}/#{token}", body)
end
@doc "PUT /applications/:application_id/commands - bulk overwrite global commands."
@spec bulk_overwrite_global_commands(id(), [map()]) :: {:ok, map()} | {:error, Error.t()}
def bulk_overwrite_global_commands(application_id, commands) when is_list(commands) do
request(:put, "/applications/#{application_id}/commands", commands)
end
@doc "PUT /applications/:application_id/guilds/:guild_id/commands - bulk overwrite guild commands."
@spec bulk_overwrite_guild_commands(id(), id(), [map()]) ::
{:ok, map()} | {:error, Error.t()}
def bulk_overwrite_guild_commands(application_id, guild_id, commands) when is_list(commands) do
request(:put, "/applications/#{application_id}/guilds/#{guild_id}/commands", commands)
end
@typedoc "A Discord snowflake id, as a string or integer."
@type id :: String.t() | integer()
end

View file

@ -0,0 +1,24 @@
defmodule Dexcord.Api.Channels do
@moduledoc """
Channel endpoints, declared with the `Dexcord.Api.Endpoint` DSL.
Every function here is also re-exported from `Dexcord.Api` as a delegate, so
callers may use either `Dexcord.Api.get_channel/1` or
`Dexcord.Api.Channels.get_channel/1`.
`get_channel/1` returns the polymorphic `Dexcord.Channel` (the concrete
struct for the channel's `type`); `start_thread_with_message/4` returns the
created thread channel and honors an audit-log `:reason` opt.
"""
use Dexcord.Api.Endpoint
endpoint :get_channel, :get, "/channels/:channel_id", returns: Dexcord.Channel
endpoint :start_thread_with_message,
:post,
"/channels/:channel_id/messages/:message_id/threads",
body: true,
binary_wrap: :name,
reason: true,
returns: Dexcord.Channel
end

View file

@ -0,0 +1,22 @@
defmodule Dexcord.Api.Commands do
@moduledoc """
Application (slash) command endpoints, declared with the
`Dexcord.Api.Endpoint` DSL.
Both bulk-overwrite endpoints take a top-level list body (each element a
command definition map/struct) and return `[Dexcord.ApplicationCommand]`.
"""
use Dexcord.Api.Endpoint
endpoint :bulk_overwrite_global_commands,
:put,
"/applications/:application_id/commands",
body: true,
returns: [Dexcord.ApplicationCommand]
endpoint :bulk_overwrite_guild_commands,
:put,
"/applications/:application_id/guilds/:guild_id/commands",
body: true,
returns: [Dexcord.ApplicationCommand]
end

View file

@ -0,0 +1,29 @@
defmodule Dexcord.Api.Interactions do
@moduledoc """
Interaction response endpoints, declared with the `Dexcord.Api.Endpoint` DSL.
`create_interaction_response/3` stays `:raw` a `204` becomes `{:ok, nil}`
naturally, and the richer `with_response` callback shapes arrive in a later
phase. The followup/edit endpoints return a `Dexcord.Message`.
"""
use Dexcord.Api.Endpoint
endpoint :create_interaction_response,
:post,
"/interactions/:interaction_id/:token/callback",
body: true,
files: true,
returns: :raw
endpoint :edit_original_interaction_response,
:patch,
"/webhooks/:application_id/:token/messages/@original",
body: true,
files: true,
returns: Dexcord.Message
endpoint :create_followup_message, :post, "/webhooks/:application_id/:token",
body: true,
files: true,
returns: Dexcord.Message
end

View file

@ -0,0 +1,64 @@
defmodule Dexcord.Api.Messages do
@moduledoc """
Message + reaction endpoints, declared with the `Dexcord.Api.Endpoint` DSL.
`create_message/2,3` and `edit_message/3,4` accept a binary (wrapped as
`content`), a keyword/map body, and `files:` for attachments; both return a
`Dexcord.Message`. `get_channel_messages/2` reads `:limit` plus any of
`:before` / `:after` / `:around` from its opts and encodes them into the query
string.
## Anchor precedence
Unlike the pre-DSL surface (which collapsed multiple anchors with a fixed
`before` > `after` > `around` precedence), `get_channel_messages/2` now sends
every anchor it is given. Discord expects exactly one; pass exactly one. The
Nostrum-compatible `get_channel_messages/3` locator arity enforces this by
construction.
"""
use Dexcord.Api.Endpoint
endpoint :get_channel_messages, :get, "/channels/:channel_id/messages",
query: [:limit, :before, :after, :around],
returns: [Dexcord.Message]
endpoint :create_message, :post, "/channels/:channel_id/messages",
body: true,
binary_wrap: :content,
files: true,
returns: Dexcord.Message
endpoint :edit_message, :patch, "/channels/:channel_id/messages/:message_id",
body: true,
binary_wrap: :content,
files: true,
returns: Dexcord.Message
endpoint :delete_message, :delete, "/channels/:channel_id/messages/:message_id",
reason: true,
returns: nil
endpoint :create_reaction,
:put,
"/channels/:channel_id/messages/:message_id/reactions/:emoji/@me",
returns: nil
@doc """
Nostrum-compatible locator arity for `get_channel_messages/2`.
`locator` is `{:before, id}` / `{:after, id}` / `{:around, id}` or `{}` for no
anchor; it is normalized onto the keyword form.
"""
def get_channel_messages(channel_id, limit, locator) when is_integer(limit) do
opts =
case locator do
{} ->
[limit: limit]
{anchor, id} when anchor in [:before, :after, :around] ->
[{:limit, limit}, {anchor, id}]
end
get_channel_messages(channel_id, opts)
end
end

20
lib/dexcord/api/misc.ex Normal file
View file

@ -0,0 +1,20 @@
defmodule Dexcord.Api.Misc do
@moduledoc """
Gateway / application-info / guild endpoints, declared with the
`Dexcord.Api.Endpoint` DSL.
`get_gateway_bot/0` stays `:raw` its `url` / `shards` / `session_start_limit`
shape has no model and the gateway reads it as a raw map.
`get_current_application/0` returns a `Dexcord.ApplicationInfo`.
"""
use Dexcord.Api.Endpoint
endpoint :get_gateway_bot, :get, "/gateway/bot", returns: :raw
endpoint :get_current_application, :get, "/oauth2/applications/@me",
returns: Dexcord.ApplicationInfo
endpoint :get_guild, :get, "/guilds/:guild_id",
query: [:with_counts],
returns: Dexcord.Guild
end

62
lib/dexcord/api/users.ex Normal file
View file

@ -0,0 +1,62 @@
defmodule Dexcord.Api.Users do
@moduledoc """
User + DM endpoints, declared with the `Dexcord.Api.Endpoint` DSL.
`create_dm/1` keeps its historical shape: given a snowflake-ish `user_id` it
opens (or fetches) a DM channel. It is registered by hand rather than via
`endpoint/4` because the generated `create_dm(body, opts \\ [])` head would
collide with the snowflake sugar's `create_dm/1`; the manual spec still lands
in the `__endpoints__/0` route table so coverage tooling sees it.
"""
use Dexcord.Api.Endpoint
endpoint :get_current_user, :get, "/users/@me", returns: Dexcord.User
endpoint :get_user, :get, "/users/:user_id", returns: Dexcord.User
@create_dm_spec %{
name: :create_dm,
method: :post,
path: "/users/@me/channels",
segments: ["users", "@me", "channels"],
query: [],
body: true,
binary_wrap: nil,
files: false,
reason: false,
returns: Dexcord.Channel
}
@dexcord_endpoints @create_dm_spec
@doc """
`POST /users/@me/channels` open (or fetch) a DM channel.
Accepts a snowflake-ish `user_id` (wrapped as `recipient_id`) or an explicit
keyword/map body.
"""
def create_dm(user_id_or_body, opts \\ [])
def create_dm(body, opts) when is_list(body) or is_map(body) do
Dexcord.Api.Endpoint.run(@create_dm_spec, [], body, opts)
end
def create_dm(user_id, _opts) do
case Dexcord.Snowflake.cast(user_id) do
{:ok, id} ->
Dexcord.Api.Endpoint.run(
@create_dm_spec,
[],
[recipient_id: Integer.to_string(id)],
[]
)
:error ->
{:error,
%Dexcord.Api.Error{
status: nil,
code: nil,
message: "invalid value for path parameter :user_id",
errors: nil
}}
end
end
end

View file

@ -44,10 +44,11 @@ defmodule Dexcord.Config do
Stored under a dedicated `:persistent_term` key, separate from the config map,
so it can be written after boot without republishing the whole config.
"""
@spec put_application_id(String.t()) :: :ok
def put_application_id(id) when is_binary(id), do: :persistent_term.put(@app_id_key, id)
@spec put_application_id(Dexcord.Snowflake.t() | String.t()) :: :ok
def put_application_id(id) when is_binary(id) or is_integer(id),
do: :persistent_term.put(@app_id_key, id)
@doc "Returns the cached application id, or `nil` if not yet resolved."
@spec application_id() :: String.t() | nil
@spec application_id() :: Dexcord.Snowflake.t() | String.t() | nil
def application_id, do: :persistent_term.get(@app_id_key, nil)
end

View file

@ -109,7 +109,7 @@ defmodule Dexcord.Slash.Registrar do
defp resolve_app_id do
case Dexcord.Api.get_current_application() do
{:ok, %{"id" => id}} when is_binary(id) ->
{:ok, %Dexcord.ApplicationInfo{id: id}} when not is_nil(id) ->
{:ok, id}
other ->

View file

@ -0,0 +1,59 @@
defmodule Dexcord.ApiFacadeTest do
@moduledoc """
Guards the generated `Dexcord.Api` facade: every public endpoint head in every
group module must be re-exported from `Dexcord.Api` at the same arity, so the
`module_info(:exports)` delegation can never silently drop an endpoint.
"""
use ExUnit.Case, async: true
test "every group endpoint export is re-exported from Dexcord.Api" do
for group <- Dexcord.Api.__endpoint_groups__(),
{name, arity} <- group.module_info(:exports),
name not in [:module_info, :__info__, :__endpoints__] do
assert function_exported?(Dexcord.Api, name, arity),
"Dexcord.Api is missing a delegate for #{inspect(group)}.#{name}/#{arity}"
end
end
test "__endpoint_groups__/0 lists the six declared groups" do
assert Dexcord.Api.__endpoint_groups__() == [
Dexcord.Api.Channels,
Dexcord.Api.Messages,
Dexcord.Api.Users,
Dexcord.Api.Interactions,
Dexcord.Api.Commands,
Dexcord.Api.Misc
]
end
test "the facade covers historically-load-bearing endpoint arities" do
# These are the arities depended on by the internal call sites (gateway,
# prefix, registrar, slash) and by external Nostrum-compatible callers.
for {name, arity} <- [
get_gateway_bot: 0,
get_current_user: 0,
get_current_application: 0,
get_user: 1,
get_channel: 1,
get_guild: 1,
create_dm: 1,
get_channel_messages: 1,
get_channel_messages: 2,
get_channel_messages: 3,
create_message: 2,
create_message: 3,
edit_message: 3,
delete_message: 2,
create_reaction: 3,
start_thread_with_message: 3,
create_interaction_response: 3,
edit_original_interaction_response: 3,
create_followup_message: 3,
bulk_overwrite_global_commands: 2,
bulk_overwrite_guild_commands: 3
] do
assert function_exported?(Dexcord.Api, name, arity),
"expected Dexcord.Api.#{name}/#{arity} to exist"
end
end
end

View file

@ -46,7 +46,7 @@ defmodule Dexcord.ApiIntegrationTest do
test "auth + user-agent headers are present on every request" do
FakeRest.stub(:get, "/users/@me", FakeRest.resp(200, body: ~s({"id":"42"})))
assert {:ok, %{"id" => "42"}} = Api.get_current_user()
assert {:ok, %Dexcord.User{id: 42}} = Api.get_current_user()
assert_receive {:rest_hit, info}
headers = Map.new(info.headers)
@ -135,7 +135,7 @@ defmodule Dexcord.ApiIntegrationTest do
{elapsed_us, result} = :timer.tc(fn -> Api.create_message(5, "hi") end)
assert {:ok, %{"id" => "ok"}} = result
assert {:ok, %Dexcord.Message{id: "ok"}} = result
# Exactly two hits: the 429 and the successful retry - and no more.
assert_receive {:rest_hit, _}
assert_receive {:rest_hit, _}
@ -173,7 +173,7 @@ defmodule Dexcord.ApiIntegrationTest do
{elapsed_us, {:ok, _}} = :timer.tc(fn -> Api.get_guild(8) end)
assert elapsed_us >= 150_000, "unrelated route was not blocked by the global lock"
assert {:ok, %{"id" => "a"}} = Task.await(task, 5_000)
assert {:ok, %Dexcord.Message{id: "a"}} = Task.await(task, 5_000)
end
test "a wait that would exceed the request deadline returns an error tuple" do
@ -232,13 +232,13 @@ defmodule Dexcord.ApiIntegrationTest do
assert catch_error(Api.create_message(999, %{"content" => self()}))
# A normal follow-up to the same route completes.
assert {:ok, %{"id" => "ok"}} = Api.create_message(999, "recovered")
assert {:ok, %Dexcord.Message{id: "ok"}} = Api.create_message(999, "recovered")
end
test "get_channel_messages/2 with a limit only builds a plain query" do
FakeRest.stub(:get, "/channels/1/messages", FakeRest.resp(200, body: ~s([{"id":"m1"}])))
assert {:ok, [%{"id" => "m1"}]} = Api.get_channel_messages(1, limit: 50)
assert {:ok, [%Dexcord.Message{id: "m1"}]} = Api.get_channel_messages(1, limit: 50)
assert_receive {:rest_hit, info}
assert info.method == "GET"
@ -265,14 +265,24 @@ defmodule Dexcord.ApiIntegrationTest do
assert info.query_string == ""
end
test "get_channel_messages/2 emits at most one anchor (before > after > around)" do
test "get_channel_messages/2 now emits every anchor it is given (no precedence collapse)" do
FakeRest.stub(:get, "/channels/1/messages", FakeRest.resp(200, body: "[]"))
assert {:ok, []} =
Api.get_channel_messages(1, before: "b", after: "a", around: "r", limit: 5)
assert_receive {:rest_hit, info}
assert query(info) == %{"limit" => "5", "before" => "b"}
# Behavior change from the pre-DSL surface: the old `message_query/1` kept
# only the highest-precedence anchor (before > after > around). The DSL query
# builder forwards every declared query key present in opts, so the caller is
# now responsible for passing exactly one anchor (the /3 locator arity does).
assert query(info) == %{
"limit" => "5",
"before" => "b",
"after" => "a",
"around" => "r"
}
end
test "get_channel_messages/3 normalizes the Nostrum-style locator" do
@ -287,15 +297,18 @@ defmodule Dexcord.ApiIntegrationTest do
assert query(info2) == %{"limit" => "25"}
end
test "start_thread_with_message posts a string-keyed body with the name" do
test "start_thread_with_message posts a string-keyed body and decodes the thread" do
FakeRest.stub(
:post,
"/channels/1/messages/2/threads",
FakeRest.resp(201, body: ~s({"id":"t1"}))
FakeRest.resp(201, body: ~s({"id":"123","type":11}))
)
assert {:ok, %{"id" => "t1"}} =
Api.start_thread_with_message(1, 2, "chat", auto_archive_duration: 1440)
# The extra body fields now travel in the keyword/map body argument (the old
# surface merged them from a trailing opts list; the DSL's opts are reserved
# for query/reason/files).
assert {:ok, %Dexcord.Thread{id: 123}} =
Api.start_thread_with_message(1, 2, name: "chat", auto_archive_duration: 1440)
assert_receive {:rest_hit, info}
assert info.method == "POST"

View file

@ -71,10 +71,10 @@ defmodule Dexcord.ErgonomicsIntegrationTest do
FakeRest.stub(
:get,
"/oauth2/applications/@me",
FakeRest.resp(200, body: ~s({"id":"app-erg"}))
FakeRest.resp(200, body: ~s({"id":"555"}))
)
FakeRest.stub(:put, "/applications/app-erg/commands", FakeRest.resp(200, body: "[]"))
FakeRest.stub(:put, "/applications/555/commands", FakeRest.resp(200, body: "[]"))
fake = start_supervised!({FakeGateway, test_pid: self(), hello_interval: 80})
:ok = start_bot(fake)

View file

@ -37,43 +37,46 @@ defmodule Dexcord.RegistrarIntegrationTest do
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
FakeRest.subscribe(self())
# The application/guild ids are numeric snowflakes: `get_current_application`
# now decodes to `%Dexcord.ApplicationInfo{}` (id cast to an integer) and the
# `bulk_overwrite_*` endpoints cast their `*_id` path params via
# `Dexcord.Snowflake.cast/1`.
FakeRest.stub(
:get,
"/oauth2/applications/@me",
FakeRest.resp(200, body: ~s({"id":"app-99"}))
FakeRest.resp(200, body: ~s({"id":"99"}))
)
:ok
end
test "global mode overwrites application commands and caches the app id" do
FakeRest.stub(:put, "/applications/app-99/commands", FakeRest.resp(200, body: "[]"))
FakeRest.stub(:put, "/applications/99/commands", FakeRest.resp(200, body: "[]"))
config = %{slash: Commands, slash_guild_ids: nil}
assert Registrar.run(config) == :ok
assert Dexcord.Config.application_id() == "app-99"
assert Dexcord.Config.application_id() == 99
assert_receive {:rest_hit, %{method: "GET", path: "/oauth2/applications/@me"}}
assert_receive {:rest_hit,
%{method: "PUT", path: "/applications/app-99/commands", body: body}}
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/99/commands", body: body}}
assert JSON.decode!(body) == [%{"name" => "ping", "description" => "Pong!"}]
end
test "guild mode overwrites each guild and NEVER wipes global commands" do
FakeRest.stub(:put, "/applications/app-99/guilds/g1/commands", FakeRest.resp(200, body: "[]"))
FakeRest.stub(:put, "/applications/app-99/guilds/g2/commands", FakeRest.resp(200, body: "[]"))
FakeRest.stub(:put, "/applications/99/guilds/1/commands", FakeRest.resp(200, body: "[]"))
FakeRest.stub(:put, "/applications/99/guilds/2/commands", FakeRest.resp(200, body: "[]"))
config = %{slash: Commands, slash_guild_ids: ["g1", "g2"]}
config = %{slash: Commands, slash_guild_ids: ["1", "2"]}
assert Registrar.run(config) == :ok
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/guilds/g1/commands"}}
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/guilds/g2/commands"}}
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/99/guilds/1/commands"}}
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/99/guilds/2/commands"}}
# The global-commands route must never be hit in guild (dev) mode.
refute_received {:rest_hit, %{path: "/applications/app-99/commands"}}
refute_received {:rest_hit, %{path: "/applications/99/commands"}}
end
test "empty commands in global mode SKIPS the overwrite (never wipes) and logs a hint" do
@ -86,7 +89,7 @@ defmodule Dexcord.RegistrarIntegrationTest do
assert_receive {:rest_hit, %{method: "GET", path: "/oauth2/applications/@me"}}
# The global-commands overwrite must NOT be attempted for an empty list.
refute_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/commands"}}
refute_receive {:rest_hit, %{method: "PUT", path: "/applications/99/commands"}}
assert log =~ "commands/0 is empty in global mode"
assert log =~ "bulk_overwrite_global_commands"
@ -97,7 +100,7 @@ defmodule Dexcord.RegistrarIntegrationTest do
Dexcord.EnvSandbox.sandbox_env()
Application.put_env(:dexcord, :registrar_retry_delays, [0, 0])
FakeRest.stub(:put, "/applications/app-99/commands", FakeRest.resp(403, body: "forbidden"))
FakeRest.stub(:put, "/applications/99/commands", FakeRest.resp(403, body: "forbidden"))
config = %{slash: Commands, slash_guild_ids: nil}
@ -108,10 +111,10 @@ defmodule Dexcord.RegistrarIntegrationTest do
end)
# Exactly 3 overwrite attempts hit the fake, and not a 4th.
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/commands"}}
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/commands"}}
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/commands"}}
refute_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/commands"}}
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/99/commands"}}
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/99/commands"}}
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/99/commands"}}
refute_receive {:rest_hit, %{method: "PUT", path: "/applications/99/commands"}}
assert log =~ "giving up after 3 attempt(s)"
end

View file

@ -41,9 +41,9 @@ defmodule Dexcord.RegistrarTreeTest do
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
FakeRest.subscribe(self())
FakeRest.stub(:get, "/oauth2/applications/@me", FakeRest.resp(200, body: ~s({"id":"app-1"})))
FakeRest.stub(:get, "/oauth2/applications/@me", FakeRest.resp(200, body: ~s({"id":"1"})))
# Registration fails forever with a 4xx.
FakeRest.stub(:put, "/applications/app-1/commands", FakeRest.resp(403, body: "forbidden"))
FakeRest.stub(:put, "/applications/1/commands", FakeRest.resp(403, body: "forbidden"))
:ok
end
@ -63,10 +63,10 @@ defmodule Dexcord.RegistrarTreeTest do
# Exactly 3 overwrite attempts hit the fake (2 retries), then the Registrar
# gives up - no 4th attempt.
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-1/commands"}}, 2_000
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-1/commands"}}, 2_000
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-1/commands"}}, 2_000
refute_receive {:rest_hit, %{method: "PUT", path: "/applications/app-1/commands"}}, 500
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/1/commands"}}, 2_000
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/1/commands"}}, 2_000
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/1/commands"}}, 2_000
refute_receive {:rest_hit, %{method: "PUT", path: "/applications/1/commands"}}, 500
# The whole tree is unharmed: supervisor and gateway are alive.
assert Process.alive?(sup)

View file

@ -123,26 +123,29 @@ defmodule Dexcord.SlashTest do
:ok
end
# Interaction/application ids must be numeric snowflakes: the typed endpoint
# surface casts `:interaction_id` / `:application_id` path params via
# `Dexcord.Snowflake.cast/1`. The interaction token is an opaque string.
@itx %{
"id" => "int-id",
"id" => "100",
"token" => "int-token",
"application_id" => "app-id",
"application_id" => "200",
"data" => %{"name" => "ping"}
}
test "respond/2 with a string sends a type-4 content response to the callback route" do
FakeRest.stub(:post, "/interactions/int-id/int-token/callback", FakeRest.resp(204))
FakeRest.stub(:post, "/interactions/100/int-token/callback", FakeRest.resp(204))
assert {:ok, nil} = Slash.respond(@itx, "pong")
assert_receive {:rest_hit, info}
assert info.method == "POST"
assert info.path == "/interactions/int-id/int-token/callback"
assert info.path == "/interactions/100/int-token/callback"
assert JSON.decode!(info.body) == %{"type" => 4, "data" => %{"content" => "pong"}}
end
test "respond/2 with a map maps ephemeral: true to flags 64 and passes embeds through" do
FakeRest.stub(:post, "/interactions/int-id/int-token/callback", FakeRest.resp(204))
FakeRest.stub(:post, "/interactions/100/int-token/callback", FakeRest.resp(204))
assert {:ok, nil} =
Slash.respond(@itx, %{content: "secret", embeds: [%{title: "x"}], ephemeral: true})
@ -156,7 +159,7 @@ defmodule Dexcord.SlashTest do
end
test "respond/2 accepts string-keyed data maps too" do
FakeRest.stub(:post, "/interactions/int-id/int-token/callback", FakeRest.resp(204))
FakeRest.stub(:post, "/interactions/100/int-token/callback", FakeRest.resp(204))
assert {:ok, nil} = Slash.respond(@itx, %{"content" => "hi", "ephemeral" => true})
@ -166,7 +169,7 @@ defmodule Dexcord.SlashTest do
end
test "respond/2 passes a caller-supplied integer flags through unchanged" do
FakeRest.stub(:post, "/interactions/int-id/int-token/callback", FakeRest.resp(204))
FakeRest.stub(:post, "/interactions/100/int-token/callback", FakeRest.resp(204))
assert {:ok, nil} = Slash.respond(@itx, %{content: "hi", flags: 4})
@ -176,7 +179,7 @@ defmodule Dexcord.SlashTest do
end
test "respond/2 OR-s a caller flags with the ephemeral bit (4 + 64 = 68)" do
FakeRest.stub(:post, "/interactions/int-id/int-token/callback", FakeRest.resp(204))
FakeRest.stub(:post, "/interactions/100/int-token/callback", FakeRest.resp(204))
assert {:ok, nil} = Slash.respond(@itx, %{content: "hi", flags: 4, ephemeral: true})
@ -186,7 +189,7 @@ defmodule Dexcord.SlashTest do
end
test "respond_later/1 sends a bare type-5 deferred response" do
FakeRest.stub(:post, "/interactions/int-id/int-token/callback", FakeRest.resp(204))
FakeRest.stub(:post, "/interactions/100/int-token/callback", FakeRest.resp(204))
assert {:ok, nil} = Slash.respond_later(@itx)
@ -195,28 +198,28 @@ defmodule Dexcord.SlashTest do
end
test "followup/2 posts to the application webhook route" do
FakeRest.stub(:post, "/webhooks/app-id/int-token", FakeRest.resp(200, body: ~s({"id":"9"})))
FakeRest.stub(:post, "/webhooks/200/int-token", FakeRest.resp(200, body: ~s({"id":"9"})))
assert {:ok, %{"id" => "9"}} = Slash.followup(@itx, "more")
assert {:ok, %Dexcord.Message{id: 9}} = Slash.followup(@itx, "more")
assert_receive {:rest_hit, info}
assert info.method == "POST"
assert info.path == "/webhooks/app-id/int-token"
assert info.path == "/webhooks/200/int-token"
assert JSON.decode!(info.body) == %{"content" => "more"}
end
test "edit_response/2 patches the original message route" do
FakeRest.stub(
:patch,
"/webhooks/app-id/int-token/messages/@original",
"/webhooks/200/int-token/messages/@original",
FakeRest.resp(200, body: ~s({"id":"9"}))
)
assert {:ok, %{"id" => "9"}} = Slash.edit_response(@itx, %{content: "edited"})
assert {:ok, %Dexcord.Message{id: 9}} = Slash.edit_response(@itx, %{content: "edited"})
assert_receive {:rest_hit, info}
assert info.method == "PATCH"
assert info.path == "/webhooks/app-id/int-token/messages/@original"
assert info.path == "/webhooks/200/int-token/messages/@original"
assert JSON.decode!(info.body) == %{"content" => "edited"}
end
end