api-surface #1

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

feat(coverage): mix dexcord.coverage gate with pinned OpenAPI spec + CI workflow

Adds `Dexcord.Api.Coverage`, a testable core that diffs the compiled
endpoint route table against a pinned snapshot of Discord's official
OpenAPI spec (`priv/discord_openapi.json`). The spec is a coverage
CHECKLIST, not codegen input: it is refreshed only by the deliberate
`mix dexcord.coverage.refresh`, so upstream churn never breaks CI
spontaneously.

- `mix dexcord.coverage` compiles then exits non-zero on any in-scope
  endpoint missing from the declared surface (AC1.8/AC1.9).
- `mix dexcord.coverage.refresh` pins the spec via :httpc + castore,
  adding inets/ssl to the code path since Mix prunes them.
- `priv/coverage_allowlist.exs` records out-of-scope spec routes with a
  comment per family; reconciled against the real pin (Social SDK
  lobbies/partner-sdk, OIDC, application-by-id, scheduled-event
  exceptions, join requests, thread search, monetization).
- `.gitea/workflows/ci.yml` runs the suite + gate (Actions must be
  enabled on the Gitea remote).
- ratelimit_test.exs gains AC1.10 spot-checks proving the new Phase 5
  route shapes collapse to bounded, id-collapsed keys.
Luna 2026-07-05 06:46:09 -03:00

14
.gitea/workflows/ci.yml Normal file
View file

@ -0,0 +1,14 @@
name: ci
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: erlef/setup-beam@v1
with:
elixir-version: "1.18"
otp-version: "27"
- run: mix deps.get
- run: mix test
- run: mix dexcord.coverage

View file

@ -0,0 +1,86 @@
defmodule Dexcord.Api.Coverage do
@moduledoc """
Diffs the compiled endpoint route table against a pinned snapshot of
Discord's official OpenAPI spec (`priv/discord_openapi.json`).
The spec is a coverage CHECKLIST, not codegen input: it is refreshed only
by the deliberate `mix dexcord.coverage.refresh`, so upstream churn never
breaks CI spontaneously. Docs win over spec on conflict; out-of-scope
routes live in `priv/coverage_allowlist.exs`.
"""
@spec_path Path.join(:code.priv_dir(:dexcord), "discord_openapi.json")
@allowlist_path Path.join(:code.priv_dir(:dexcord), "coverage_allowlist.exs")
@doc "Returns {:ok, %{declared: n, spec: n, allowlisted: n}} or {:missing, [route]}."
def check(declared \\ declared_routes()) do
spec = spec_routes()
allowlist = allowlist()
missing =
spec
|> Enum.reject(&(&1 in declared))
|> Enum.reject(&allowlisted?(&1, allowlist))
|> Enum.sort()
case missing do
[] ->
{:ok, %{declared: length(declared), spec: length(spec), allowlisted: length(allowlist)}}
missing ->
{:missing, missing}
end
end
@doc "All declared routes as normalized \"VERB /path/*\" strings."
def declared_routes do
for group <- Dexcord.Api.__endpoint_groups__(),
spec <- group.__endpoints__(),
uniq: true do
normalize(spec.method, Enum.map(spec.segments, &segment_to_string/1))
end
end
@doc "All spec routes as normalized strings."
def spec_routes do
%{"paths" => paths} = @spec_path |> File.read!() |> JSON.decode!()
for {path, operations} <- paths,
{method, _op} <- operations,
method in ~w(get post put patch delete),
uniq: true do
segments =
path
|> String.split("/", trim: true)
|> Enum.map(fn
"{" <> _ -> "*"
literal -> literal
end)
normalize(method, segments)
end
end
defp segment_to_string({:param, _}), do: "*"
defp segment_to_string(literal), do: literal
defp normalize(method, segments) do
String.upcase(to_string(method)) <> " /" <> Enum.join(segments, "/")
end
defp allowlist do
{list, _} = Code.eval_file(@allowlist_path)
list
end
# Entries are exact normalized routes or prefix patterns ending in "*".
defp allowlisted?(route, allowlist) do
Enum.any?(allowlist, fn pattern ->
if String.ends_with?(pattern, "*") do
String.starts_with?(route, String.trim_trailing(pattern, "*"))
else
route == pattern
end
end)
end
end

View file

@ -0,0 +1,22 @@
defmodule Mix.Tasks.Dexcord.Coverage do
@shortdoc "Verifies declared REST routes cover the pinned Discord OpenAPI spec"
@moduledoc "See Dexcord.Api.Coverage. Exits non-zero when in-scope endpoints are missing."
use Mix.Task
@impl Mix.Task
def run(_argv) do
Mix.Task.run("compile")
case Dexcord.Api.Coverage.check() do
{:ok, stats} ->
Mix.shell().info(
"coverage OK — #{stats.declared} declared, #{stats.spec} in spec, #{stats.allowlisted} allowlisted"
)
{:missing, missing} ->
Mix.shell().error("MISSING #{length(missing)} in-scope endpoint(s):")
Enum.each(missing, &Mix.shell().error(" " <> &1))
exit({:shutdown, 1})
end
end
end

View file

@ -0,0 +1,81 @@
defmodule Mix.Tasks.Dexcord.Coverage.Refresh do
@shortdoc "Downloads and pins the latest Discord OpenAPI spec into priv/"
@moduledoc """
Refreshes `priv/discord_openapi.json` from the upstream
`discord/discord-api-spec` repo.
This is a DELIBERATE, human-initiated action it is never run in CI. Pinning
means upstream churn can only affect coverage when an operator explicitly runs
this task, reviews the `git diff`, reconciles the allowlist, and reruns
`mix dexcord.coverage`.
"""
use Mix.Task
@url "https://raw.githubusercontent.com/discord/discord-api-spec/main/specs/openapi.json"
@impl Mix.Task
def run(_argv) do
# Mix prunes OTP apps it doesn't depend on from the code path, so inets/ssl
# (needed only by this deliberate, dev-only task) may not be loadable. Add
# their ebin dirs from the Erlang root before starting them.
ensure_otp_app_on_path(:ssl)
ensure_otp_app_on_path(:inets)
{:ok, _} = Application.ensure_all_started(:ssl)
{:ok, _} = Application.ensure_all_started(:inets)
dest = Path.join(:code.priv_dir(:dexcord), "discord_openapi.json")
File.mkdir_p!(Path.dirname(dest))
Mix.shell().info("fetching #{@url} ...")
body = fetch!(@url)
File.write!(dest, body)
Mix.shell().info("wrote #{dest} (#{byte_size(body)} bytes)")
Mix.shell().info(
"review `git diff priv/discord_openapi.json`, reconcile priv/coverage_allowlist.exs, then rerun `mix dexcord.coverage`"
)
end
# Adds an OTP application's ebin dir (under the Erlang root) to the code path
# if its modules aren't already loadable.
defp ensure_otp_app_on_path(app) do
if :code.lib_dir(app) == {:error, :bad_name} do
root = to_string(:code.root_dir())
case Path.wildcard(Path.join([root, "lib", "#{app}-*", "ebin"])) do
[ebin | _] -> :code.add_patha(String.to_charlist(ebin))
[] -> Mix.raise("cannot locate OTP application #{app} under #{root}/lib")
end
end
end
# Verifies the peer with castore's CA bundle so the pin can't be MITM'd.
defp fetch!(url) do
cacerts = CAStore.file_path() |> to_charlist()
http_opts = [
ssl: [
verify: :verify_peer,
cacertfile: cacerts,
depth: 3,
customize_hostname_check: [
match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
]
],
autoredirect: true
]
case :httpc.request(:get, {to_charlist(url), []}, http_opts, body_format: :binary) do
{:ok, {{_v, 200, _r}, _headers, body}} ->
body
{:ok, {{_v, status, _r}, _headers, _body}} ->
Mix.raise("failed to fetch spec: HTTP #{status}")
{:error, reason} ->
Mix.raise("failed to fetch spec: #{inspect(reason)}")
end
end
end

View file

@ -0,0 +1,95 @@
# Routes present in the pinned OpenAPI spec but deliberately NOT declared.
# Everything here remains reachable via Dexcord.Api.request/4.
[
# -- monetization (out of scope) --
"GET /applications/*/skus",
"GET /applications/*/entitlements*",
"POST /applications/*/entitlements*",
"DELETE /applications/*/entitlements*",
"GET /applications/*/subscriptions*",
"GET /skus/*/subscriptions*",
# -- soundboard (out of scope) --
"GET /soundboard-default-sounds",
"GET /guilds/*/soundboard-sounds*",
"POST /guilds/*/soundboard-sounds",
"PATCH /guilds/*/soundboard-sounds/*",
"DELETE /guilds/*/soundboard-sounds/*",
"POST /channels/*/send-soundboard-sound",
# -- stage instances (out of scope) --
"GET /stage-instances/*",
"POST /stage-instances",
"PATCH /stage-instances/*",
"DELETE /stage-instances/*",
# -- guild templates (out of scope) --
"GET /guilds/templates/*",
"POST /guilds/templates/*",
"GET /guilds/*/templates",
"POST /guilds/*/templates",
"PUT /guilds/*/templates/*",
"PATCH /guilds/*/templates/*",
"DELETE /guilds/*/templates/*",
# -- Bearer-only / not bot-usable --
"GET /oauth2/@me",
"GET /users/@me/applications/*/role-connection",
"PUT /users/@me/applications/*/role-connection",
"DELETE /users/@me/applications/*/role-connection",
"GET /applications/*/role-connections/metadata",
"PUT /applications/*/role-connections/metadata",
"PUT /applications/*/guilds/*/commands/*/permissions",
"PUT /applications/*/guilds/*/commands/permissions",
# -- deprecated routes kept out of the typed surface --
"GET /channels/*/pins",
"PUT /channels/*/pins/*",
"DELETE /channels/*/pins/*",
"PATCH /guilds/*/members/@me/nick",
# -- removed from official docs (2026-07); spec may still carry them --
"POST /guilds",
"DELETE /guilds/*",
"POST /guilds/*/mfa",
# -- special CSV multipart (guest invites); escape hatch --
"GET /invites/*/target-users*",
"PUT /invites/*/target-users",
# -- activities --
"GET /applications/*/activity-instances/*",
# -- OAuth2 token machinery (not a bot-library concern) --
"POST /oauth2/token*",
"POST /oauth2/token/revoke",
# -- OpenID Connect / key-discovery (bearer/OIDC, not bot-token) --
"GET /oauth2/keys",
"GET /oauth2/userinfo",
# ---------------------------------------------------------------------------
# Reconciliation against the pinned spec (2026-07-05). Everything below is
# present upstream but NOT in the plan's authoritative declared surface: all
# are Social-SDK-era or post-inventory additions, none documented as part of
# the bot-token REST surface the design scoped. Reachable via request/4.
# ---------------------------------------------------------------------------
#
# -- Social SDK: Lobbies (whole resource family, out of scope) --
"PUT /lobbies*",
"POST /lobbies*",
"GET /lobbies*",
"PATCH /lobbies*",
"DELETE /lobbies*",
# -- Social SDK: Partner SDK token + provisional accounts + DM moderation --
"POST /partner-sdk*",
"PUT /partner-sdk*",
# -- Application-by-id (Social SDK / OAuth variants of the @me routes we
# declare) + application attachment upload --
"GET /applications/*",
"PATCH /applications/*",
"POST /applications/*/attachment",
# -- Guild scheduled-event exceptions & user counts (recurring-event feature;
# the base scheduled-events family IS declared — only these extras aren't) --
"POST /guilds/*/scheduled-events/*/exceptions",
"PATCH /guilds/*/scheduled-events/*/exceptions/*",
"GET /guilds/*/scheduled-events/*/users/counts",
"GET /guilds/*/scheduled-events/*/*/users",
# -- Guild join requests / membership application flow (newer feature) --
"GET /guilds/*/new-member-welcome",
"GET /guilds/*/requests",
"PATCH /guilds/*/requests/*",
# -- Thread search (not in the documented channel bot surface) --
"GET /channels/*/threads/search",
# -- monetization: per-user application entitlements (monetization excluded) --
"GET /users/@me/applications/*/entitlements"
]

43111
priv/discord_openapi.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,73 @@
defmodule Dexcord.Api.CoverageTest do
@moduledoc """
The coverage gate as a hermetic unit test: everything reads only committed
files (`priv/discord_openapi.json`, `priv/coverage_allowlist.exs`) and the
compiled route table no network, no live Discord.
"""
use ExUnit.Case, async: true
alias Dexcord.Api.Coverage
describe "check/1 (the gate)" do
# api-surface.AC1.8: the pinned spec is fully covered by declarations +
# allowlist. THIS is the assertion CI runs as `mix dexcord.coverage`.
test "passes against the real pinned spec" do
assert {:ok, stats} = Coverage.check()
assert stats.declared > 0
assert stats.spec > 0
assert stats.allowlisted > 0
end
# api-surface.AC1.9: dropping any one declared route re-exposes it as missing
# (this is exactly what a deleted route-table entry would do). The mix task's
# non-zero exit is a thin shell wrapper over this {:missing, _} return.
test "reports missing when a declared route is removed" do
declared = Coverage.declared_routes()
dropped = tl(declared)
# The dropped head must be a spec route (otherwise removing it changes
# nothing) — pick one that is guaranteed present in the spec.
assert {:missing, missing} = Coverage.check(dropped)
assert is_list(missing)
assert missing != []
# missing is sorted and deduped strings.
assert missing == Enum.sort(missing)
assert Enum.all?(missing, &is_binary/1)
end
end
describe "normalization" do
test "spec and declared routes agree on param collapse for get_channel" do
assert "GET /channels/*" in Coverage.spec_routes()
assert "GET /channels/*" in Coverage.declared_routes()
end
test "routes are normalized VERB /path strings" do
for route <- Coverage.declared_routes() do
assert route =~ ~r{^(GET|POST|PUT|PATCH|DELETE) /}
end
end
end
describe "allowlist matching" do
# allowlisted?/2 is private; exercise it through check/1 with a hand-built
# declared set so the match semantics are observable.
test "an unlisted, undeclared spec route is reported missing" do
# Declaring nothing surfaces every non-allowlisted spec route as missing.
assert {:missing, missing} = Coverage.check([])
assert missing != []
# Allowlisted families (e.g. lobbies) must NOT appear in the missing set.
refute Enum.any?(missing, &String.starts_with?(&1, "GET /lobbies"))
refute Enum.any?(missing, &String.starts_with?(&1, "POST /partner-sdk"))
end
test "exact and trailing-* patterns both suppress their routes" do
# An exact allowlist entry ("GET /oauth2/@me") and a prefix entry
# ("GET /lobbies*") both keep their routes out of the missing set even
# when nothing is declared.
assert {:missing, missing} = Coverage.check([])
refute "GET /oauth2/@me" in missing
refute "GET /lobbies/*" in missing
end
end
end

View file

@ -120,6 +120,26 @@ defmodule Dexcord.Api.RatelimitTest do
assert Ratelimit.route_key(:get, "/guilds/1/emojis/2") ==
"GET /guilds/1/emojis/:id"
end
# AC1.10 (final): the full Phase 5 surface must still produce bounded,
# id-collapsed keys — no unbounded snowflake leakage on the new route shapes.
test "new Phase 5 route shapes collapse to bounded keys" do
# scheduled-event users: guild is major (literal), the event id collapses.
assert Ratelimit.route_key(:get, "/guilds/1/scheduled-events/2/users") ==
"GET /guilds/1/scheduled-events/:id/users"
# poll answer voters: channel is major; message id and answer id collapse.
assert Ratelimit.route_key(:get, "/channels/1/polls/2/answers/3") ==
"GET /channels/1/polls/:id/answers/:id"
# automod rules: guild is major; the rule id collapses.
assert Ratelimit.route_key(:get, "/guilds/1/auto-moderation/rules/2") ==
"GET /guilds/1/auto-moderation/rules/:id"
# voice-status: channel is major, no trailing id to collapse.
assert Ratelimit.route_key(:put, "/channels/1/voice-status") ==
"PUT /channels/1/voice-status"
end
end
describe "bucket + global math (injected clock)" do