api-surface #1

Merged
luna merged 51 commits from api-surface into mistress 2026-07-05 14:43:13 +00:00
7 changed files with 458 additions and 6 deletions
Showing only changes of commit 6830abd7e3 - Show all commits

feat(api): guilds + members + roles endpoint rollout

Luna 2026-07-05 06:25:59 -03:00

View file

@ -400,6 +400,9 @@ defmodule Dexcord.Api do
@endpoint_groups [
Dexcord.Api.Channels,
Dexcord.Api.Messages,
Dexcord.Api.Guilds,
Dexcord.Api.Members,
Dexcord.Api.Roles,
Dexcord.Api.Users,
Dexcord.Api.Interactions,
Dexcord.Api.Commands,

96
lib/dexcord/api/guilds.ex Normal file
View file

@ -0,0 +1,96 @@
defmodule Dexcord.Api.Guilds do
@moduledoc """
Guild endpoints, declared with the `Dexcord.Api.Endpoint` DSL mirrors the
Discord "Guild" resource page (plus the guild widget / welcome-screen /
onboarding / prune / audit-log sub-resources).
`get_guild/1,2` moved here from `Dexcord.Api.Misc`; the `Dexcord.Api` facade
delegate follows the group automatically. `get_guild_preview/1` decodes into
`Dexcord.Guild` (a lenient subset shape). Shapes with no model
(`get_guild_widget_image`, `get_guild_vanity_url`, prune counts, incident
actions, active-thread listings) stay `:raw`.
"""
use Dexcord.Api.Endpoint
endpoint :get_guild, :get, "/guilds/:guild_id", query: [:with_counts], returns: Dexcord.Guild
endpoint :get_guild_preview, :get, "/guilds/:guild_id/preview", returns: Dexcord.Guild
endpoint :edit_guild, :patch, "/guilds/:guild_id",
body: true,
reason: true,
returns: Dexcord.Guild
endpoint :get_guild_channels, :get, "/guilds/:guild_id/channels", returns: [Dexcord.Channel]
endpoint :create_guild_channel, :post, "/guilds/:guild_id/channels",
body: true,
binary_wrap: :name,
reason: true,
returns: Dexcord.Channel
endpoint :edit_guild_channel_positions, :patch, "/guilds/:guild_id/channels",
body: true,
returns: nil
endpoint :list_active_guild_threads, :get, "/guilds/:guild_id/threads/active", returns: :raw
endpoint :get_guild_invites, :get, "/guilds/:guild_id/invites", returns: [Dexcord.Invite]
endpoint :get_guild_integrations, :get, "/guilds/:guild_id/integrations",
returns: [Dexcord.Integration]
endpoint :delete_guild_integration, :delete, "/guilds/:guild_id/integrations/:integration_id",
reason: true,
returns: nil
endpoint :get_guild_widget_settings, :get, "/guilds/:guild_id/widget",
returns: Dexcord.GuildWidgetSettings
endpoint :edit_guild_widget, :patch, "/guilds/:guild_id/widget",
body: true,
reason: true,
returns: Dexcord.GuildWidgetSettings
endpoint :get_guild_widget, :get, "/guilds/:guild_id/widget.json", returns: Dexcord.GuildWidget
endpoint :get_guild_widget_image, :get, "/guilds/:guild_id/widget.png",
query: [:style],
returns: :raw
endpoint :get_guild_vanity_url, :get, "/guilds/:guild_id/vanity-url", returns: :raw
endpoint :get_guild_welcome_screen, :get, "/guilds/:guild_id/welcome-screen",
returns: Dexcord.WelcomeScreen
endpoint :edit_guild_welcome_screen, :patch, "/guilds/:guild_id/welcome-screen",
body: true,
reason: true,
returns: Dexcord.WelcomeScreen
endpoint :get_guild_onboarding, :get, "/guilds/:guild_id/onboarding",
returns: Dexcord.GuildOnboarding
endpoint :edit_guild_onboarding, :put, "/guilds/:guild_id/onboarding",
body: true,
reason: true,
returns: Dexcord.GuildOnboarding
endpoint :get_guild_prune_count, :get, "/guilds/:guild_id/prune",
query: [:days, :include_roles],
returns: :raw
endpoint :begin_guild_prune, :post, "/guilds/:guild_id/prune",
body: true,
reason: true,
returns: :raw
endpoint :get_guild_voice_regions, :get, "/guilds/:guild_id/regions",
returns: [Dexcord.VoiceRegion]
endpoint :edit_guild_incident_actions, :put, "/guilds/:guild_id/incident-actions",
body: true,
returns: :raw
endpoint :get_guild_audit_log, :get, "/guilds/:guild_id/audit-logs",
query: [:user_id, :action_type, :before, :after, :limit],
returns: Dexcord.AuditLog
end

View file

@ -0,0 +1,81 @@
defmodule Dexcord.Api.Members do
@moduledoc """
Guild member, ban, and voice-state endpoints, declared with the
`Dexcord.Api.Endpoint` DSL mirrors the member/ban/voice sections of the
Discord "Guild" resource page.
`bulk_guild_ban` returns the `{banned_users, failed_users}` envelope with no
model (`:raw`). The `edit_*_voice_state` endpoints return `nil` (204).
"""
use Dexcord.Api.Endpoint
endpoint :get_guild_member, :get, "/guilds/:guild_id/members/:user_id", returns: Dexcord.Member
endpoint :list_guild_members, :get, "/guilds/:guild_id/members",
query: [:limit, :after],
returns: [Dexcord.Member]
endpoint :search_guild_members, :get, "/guilds/:guild_id/members/search",
query: [:query, :limit],
returns: [Dexcord.Member]
endpoint :add_guild_member, :put, "/guilds/:guild_id/members/:user_id",
body: true,
returns: Dexcord.Member
endpoint :edit_guild_member, :patch, "/guilds/:guild_id/members/:user_id",
body: true,
reason: true,
returns: Dexcord.Member
endpoint :edit_current_member, :patch, "/guilds/:guild_id/members/@me",
body: true,
reason: true,
returns: Dexcord.Member
endpoint :add_guild_member_role, :put, "/guilds/:guild_id/members/:user_id/roles/:role_id",
reason: true,
returns: nil
endpoint :remove_guild_member_role,
:delete,
"/guilds/:guild_id/members/:user_id/roles/:role_id", reason: true, returns: nil
endpoint :remove_guild_member, :delete, "/guilds/:guild_id/members/:user_id",
reason: true,
returns: nil
endpoint :get_guild_bans, :get, "/guilds/:guild_id/bans",
query: [:limit, :before, :after],
returns: [Dexcord.Ban]
endpoint :get_guild_ban, :get, "/guilds/:guild_id/bans/:user_id", returns: Dexcord.Ban
endpoint :create_guild_ban, :put, "/guilds/:guild_id/bans/:user_id",
body: true,
reason: true,
returns: nil
endpoint :remove_guild_ban, :delete, "/guilds/:guild_id/bans/:user_id",
reason: true,
returns: nil
endpoint :bulk_guild_ban, :post, "/guilds/:guild_id/bulk-ban",
body: true,
reason: true,
returns: :raw
endpoint :get_current_user_voice_state, :get, "/guilds/:guild_id/voice-states/@me",
returns: Dexcord.VoiceState
endpoint :get_user_voice_state, :get, "/guilds/:guild_id/voice-states/:user_id",
returns: Dexcord.VoiceState
endpoint :edit_current_user_voice_state, :patch, "/guilds/:guild_id/voice-states/@me",
body: true,
returns: nil
endpoint :edit_user_voice_state, :patch, "/guilds/:guild_id/voice-states/:user_id",
body: true,
returns: nil
end

View file

@ -1,11 +1,14 @@
defmodule Dexcord.Api.Misc do
@moduledoc """
Gateway / application-info / guild endpoints, declared with the
Gateway / application-info 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`.
`get_guild` moved to `Dexcord.Api.Guilds` the `Dexcord.Api` facade delegate
follows the group automatically.
"""
use Dexcord.Api.Endpoint
@ -13,8 +16,4 @@ defmodule Dexcord.Api.Misc do
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

37
lib/dexcord/api/roles.ex Normal file
View file

@ -0,0 +1,37 @@
defmodule Dexcord.Api.Roles do
@moduledoc """
Guild role endpoints, declared with the `Dexcord.Api.Endpoint` DSL mirrors
the role section of the Discord "Guild" resource page.
`get_guild_role_member_counts` returns a `{role_id => count}` map with no
model (`:raw`). `edit_guild_role_positions` takes a top-level list body and
returns the full `[Dexcord.Role]` list.
"""
use Dexcord.Api.Endpoint
endpoint :get_guild_roles, :get, "/guilds/:guild_id/roles", returns: [Dexcord.Role]
endpoint :get_guild_role, :get, "/guilds/:guild_id/roles/:role_id", returns: Dexcord.Role
endpoint :get_guild_role_member_counts, :get, "/guilds/:guild_id/roles/member-counts",
returns: :raw
endpoint :create_guild_role, :post, "/guilds/:guild_id/roles",
body: true,
binary_wrap: :name,
reason: true,
returns: Dexcord.Role
endpoint :edit_guild_role_positions, :patch, "/guilds/:guild_id/roles",
body: true,
reason: true,
returns: [Dexcord.Role]
endpoint :edit_guild_role, :patch, "/guilds/:guild_id/roles/:role_id",
body: true,
reason: true,
returns: Dexcord.Role
endpoint :delete_guild_role, :delete, "/guilds/:guild_id/roles/:role_id",
reason: true,
returns: nil
end

View file

@ -63,10 +63,13 @@ defmodule Dexcord.ApiFacadeTest do
end
end
test "__endpoint_groups__/0 lists the six declared groups" do
test "__endpoint_groups__/0 lists all declared groups" do
assert Dexcord.Api.__endpoint_groups__() == [
Dexcord.Api.Channels,
Dexcord.Api.Messages,
Dexcord.Api.Guilds,
Dexcord.Api.Members,
Dexcord.Api.Roles,
Dexcord.Api.Users,
Dexcord.Api.Interactions,
Dexcord.Api.Commands,

View file

@ -0,0 +1,233 @@
defmodule Dexcord.ApiGuildsTest do
@moduledoc """
Representative wire tests for the `Dexcord.Api.Guilds`, `Dexcord.Api.Members`,
and `Dexcord.Api.Roles` groups (Phase 5 Task 3): top-level-array bodies, the
binary widget.png, audit-log decode, and the moved `get_guild`.
"""
use ExUnit.Case, async: false
alias Dexcord.Api
alias Dexcord.Api.Ratelimit
alias Dexcord.FakeRest
@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
# --- Guilds -------------------------------------------------------------
test "get_guild still works after moving out of Misc, honoring with_counts" do
FakeRest.stub(:get, "/guilds/1", FakeRest.resp(200, body: ~s({"id":"1","name":"g"})))
assert {:ok, %Dexcord.Guild{id: 1, name: "g"}} = Api.get_guild(1, with_counts: true)
assert_receive {:rest_hit, info}
assert info.query_string == "with_counts=true"
end
test "edit_guild PATCHes a body and decodes the Guild with a reason" do
FakeRest.stub(:patch, "/guilds/1", FakeRest.resp(200, body: ~s({"id":"1","name":"new"})))
assert {:ok, %Dexcord.Guild{name: "new"}} =
Api.edit_guild(1, %{"name" => "new"}, reason: "rebrand")
assert_receive {:rest_hit, info}
assert Map.new(info.headers)["x-audit-log-reason"] == "rebrand"
end
test "edit_guild_channel_positions sends a TOP-LEVEL array body and returns nil" do
FakeRest.stub(:patch, "/guilds/1/channels", FakeRest.resp(204))
assert {:ok, nil} =
Api.edit_guild_channel_positions(1, [
%{"id" => "2", "position" => 1},
%{"id" => "3", "position" => 2}
])
assert_receive {:rest_hit, info}
assert JSON.decode!(info.body) == [
%{"id" => "2", "position" => 1},
%{"id" => "3", "position" => 2}
]
end
test "get_guild_channels decodes a list of concrete channel structs" do
FakeRest.stub(
:get,
"/guilds/1/channels",
FakeRest.resp(200, body: ~s([{"id":"2","type":0},{"id":"3","type":2}]))
)
assert {:ok, [%Dexcord.TextChannel{id: 2}, %Dexcord.VoiceChannel{id: 3}]} =
Api.get_guild_channels(1)
end
test "get_guild_widget_settings decodes the settings struct" do
FakeRest.stub(
:get,
"/guilds/1/widget",
FakeRest.resp(200, body: ~s({"enabled":true,"channel_id":"9"}))
)
assert {:ok, %Dexcord.GuildWidgetSettings{enabled: true, channel_id: 9}} =
Api.get_guild_widget_settings(1)
end
test "get_guild_widget_image returns a non-JSON PNG body raw" do
png = <<137, 80, 78, 71, 13, 10, 26, 10>>
FakeRest.stub(
:get,
"/guilds/1/widget.png",
FakeRest.resp(200, headers: [{"content-type", "image/png"}], body: png)
)
assert {:ok, {:raw, ^png}} = Api.get_guild_widget_image(1, style: "banner1")
assert_receive {:rest_hit, info}
assert info.query_string == "style=banner1"
end
test "get_guild_audit_log encodes its query and decodes nested entries" do
body = ~s({"audit_log_entries":[{"id":"5","action_type":1}],"users":[{"id":"7"}]})
FakeRest.stub(:get, "/guilds/1/audit-logs", FakeRest.resp(200, body: body))
assert {:ok,
%Dexcord.AuditLog{
audit_log_entries: [%Dexcord.AuditLogEntry{id: 5}],
users: [%Dexcord.User{id: 7}]
}} = Api.get_guild_audit_log(1, user_id: "7", action_type: 1, limit: 50)
assert_receive {:rest_hit, info}
assert URI.decode_query(info.query_string) == %{
"user_id" => "7",
"action_type" => "1",
"limit" => "50"
}
end
# --- Members ------------------------------------------------------------
test "get_guild_member decodes a Member" do
FakeRest.stub(
:get,
"/guilds/1/members/2",
FakeRest.resp(200, body: ~s({"nick":"bob","user":{"id":"2"}}))
)
assert {:ok, %Dexcord.Member{nick: "bob", user: %Dexcord.User{id: 2}}} =
Api.get_guild_member(1, 2)
end
test "list_guild_members decodes a list and forwards paging query" do
FakeRest.stub(
:get,
"/guilds/1/members",
FakeRest.resp(200, body: ~s([{"nick":"a"},{"nick":"b"}]))
)
assert {:ok, [%Dexcord.Member{nick: "a"}, %Dexcord.Member{nick: "b"}]} =
Api.list_guild_members(1, limit: 100, after: "0")
assert_receive {:rest_hit, info}
assert URI.decode_query(info.query_string) == %{"limit" => "100", "after" => "0"}
end
test "create_guild_ban PUTs a body, returns nil, carries a reason" do
FakeRest.stub(:put, "/guilds/1/bans/2", FakeRest.resp(204))
assert {:ok, nil} =
Api.create_guild_ban(1, 2, %{"delete_message_seconds" => 3600}, reason: "spam")
assert_receive {:rest_hit, info}
assert info.method == "PUT"
assert info.path == "/guilds/1/bans/2"
assert Map.new(info.headers)["x-audit-log-reason"] == "spam"
end
test "get_guild_ban decodes a Ban" do
FakeRest.stub(
:get,
"/guilds/1/bans/2",
FakeRest.resp(200, body: ~s({"reason":"bad","user":{"id":"2"}}))
)
assert {:ok, %Dexcord.Ban{reason: "bad", user: %Dexcord.User{id: 2}}} =
Api.get_guild_ban(1, 2)
end
test "get_current_user_voice_state decodes a VoiceState" do
FakeRest.stub(
:get,
"/guilds/1/voice-states/@me",
FakeRest.resp(200, body: ~s({"channel_id":"9","session_id":"s"}))
)
assert {:ok, %Dexcord.VoiceState{channel_id: 9, session_id: "s"}} =
Api.get_current_user_voice_state(1)
end
# --- Roles --------------------------------------------------------------
test "get_guild_roles decodes a list of Roles" do
FakeRest.stub(
:get,
"/guilds/1/roles",
FakeRest.resp(200, body: ~s([{"id":"2","name":"mod"},{"id":"3","name":"admin"}]))
)
assert {:ok, [%Dexcord.Role{id: 2, name: "mod"}, %Dexcord.Role{id: 3, name: "admin"}]} =
Api.get_guild_roles(1)
end
test "create_guild_role wraps a binary as name and decodes a Role with a reason" do
FakeRest.stub(:post, "/guilds/1/roles", FakeRest.resp(200, body: ~s({"id":"9","name":"vip"})))
assert {:ok, %Dexcord.Role{id: 9, name: "vip"}} =
Api.create_guild_role(1, "vip", reason: "perk")
assert_receive {:rest_hit, info}
assert JSON.decode!(info.body) == %{"name" => "vip"}
assert Map.new(info.headers)["x-audit-log-reason"] == "perk"
end
test "edit_guild_role_positions sends a list body and decodes a list of Roles" do
FakeRest.stub(
:patch,
"/guilds/1/roles",
FakeRest.resp(200, body: ~s([{"id":"2","position":1},{"id":"3","position":2}]))
)
assert {:ok, [%Dexcord.Role{id: 2, position: 1}, %Dexcord.Role{id: 3, position: 2}]} =
Api.edit_guild_role_positions(1, [
%{"id" => "2", "position" => 1},
%{"id" => "3", "position" => 2}
])
assert_receive {:rest_hit, info}
assert is_list(JSON.decode!(info.body))
end
test "delete_guild_role returns nil with a reason" do
FakeRest.stub(:delete, "/guilds/1/roles/2", FakeRest.resp(204))
assert {:ok, nil} = Api.delete_guild_role(1, 2, reason: "obsolete")
assert_receive {:rest_hit, info}
assert Map.new(info.headers)["x-audit-log-reason"] == "obsolete"
end
end