api-surface #1
8 changed files with 43502 additions and 0 deletions
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.
commit
0a84650025
14
.gitea/workflows/ci.yml
Normal file
14
.gitea/workflows/ci.yml
Normal 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
|
||||
86
lib/dexcord/api/coverage.ex
Normal file
86
lib/dexcord/api/coverage.ex
Normal 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
|
||||
22
lib/mix/tasks/dexcord.coverage.ex
Normal file
22
lib/mix/tasks/dexcord.coverage.ex
Normal 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
|
||||
81
lib/mix/tasks/dexcord.coverage.refresh.ex
Normal file
81
lib/mix/tasks/dexcord.coverage.refresh.ex
Normal 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
|
||||
95
priv/coverage_allowlist.exs
Normal file
95
priv/coverage_allowlist.exs
Normal 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
43111
priv/discord_openapi.json
Normal file
File diff suppressed because it is too large
Load diff
73
test/dexcord/coverage_test.exs
Normal file
73
test/dexcord/coverage_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue