ADR-0050: OpenAPI 3.1 from controller annotations, one error envelope, URL versioning with Deprecation and Sunset
| Status | Accepted |
| Date | 2026-10-12 |
| Issue | #45 |
| Pull request | #145 |
| Related | ADR-0024, ADR-0026, ADR-0044 |
Context and problem statement
There was no machine-readable description of the HTTP API: integrators and the SDK authors (TypeScript SDK
#46, server SDKs #47)
had to read the docs and the Elixir source. Error bodies depended on where an error was raised: FallbackController
answered {"error": "..."} for strings and {"errors": {"detail": ...}} for atoms, changesets were
{"errors": {field: [...]}}, ConvergerAuth sent {"error": {"code": "Unauthorized", "message": ...}}, TenantAuth
{"error": "Unauthorized: ..."}, the database-pressure plug {"errors": {"detail", "code"}}, AdminAuth plain text
and refused socket connections an empty body. A client could not handle errors generically, and a generated SDK could
not type them. There was also no written rule for how the API may change.
Decision drivers
- One source of truth: the description must come from the code and cannot drift silently.
- SDKs generate types from it, so every resource needs a named schema.
- One error shape for every path, including plugs and exception handlers, aligned with the Protocol v1
errorframe codes clients already handle on the WebSocket. - No new runtime dependency beyond a mature library; no behaviour change for successful responses.
- Clients need predictable rules for change and retirement.
Considered options
- open_api_spex annotations on the controllers, document generated and committed - spec next to the code, checked in tests and CI.
- Hand-written
openapi.yaml- flexible, but nothing ties it to the code; it drifts. - Generate from tests (recorded responses) - accurate for what tests cover, silent about the rest, no named schemas.
For errors: (a) one envelope {"error": {code, message, details, request_id}} everywhere, (b) RFC 9457
application/problem+json, (c) keep the shapes and document them.
Pros and cons of the options
Option 1
- Good, because the operation sits next to the action and
OpenApiSpex.TestAssertionschecks real responses against it. - Good, because the router supplies the paths: a new route without an operation is caught by a test.
- Bad, because open_api_spex models OpenAPI 3.0; 3.1 needs an export step (below).
Option 2
- Good, because any OpenAPI 3.1 feature is available.
- Bad, because it duplicates the controllers and nothing fails when they diverge.
Option 3
- Good, because examples are real.
- Bad, because untested paths are missing and schemas are anonymous.
Envelope (a) versus problem+json (b)
- (a) is what
ConvergerAuthand the Protocol v1 frame already looked like (code+message+details), so the SDKs share one error type across REST and WebSocket;request_idties a report to the server logs. - (b) is a standard, but its
typeURIs andtitle/detailsplit add nothing for these clients, and it would diverge from the frame shape.
Decision
Chosen: option 1 with envelope (a).
- Spec. Controllers
use OpenApiSpex.ControllerSpecsand declare anoperationper action with a stableoperationId. Resource schemas and the shared error responses live inConvergerWeb.ApiSchemasas named components;ConvergerWeb.ApiSpeccollects the paths from the router.ApiSpec.to_json/0exports OpenAPI 3.1: it rewrites 3.0nullabletotype: [..., "null"](oranyOfwithnullfor references) and sorts keys.mix openapi.spec.json(an alias ofmix converger.openapi) writespriv/static/openapi.json, which is committed;--checkfails on drift. CI runs the check andredocly lint. - Swagger UI at
/api/docsand the document at/api/openapi.json, on in dev and test, off in prod unlessAPI_DOCS_ENABLED=true(404 otherwise). Bearer and API-key security schemes make "Try it out" work. The page gets its own CSP: the cdnjs origin and a per-request script nonce. - Errors.
ConvergerWeb.APIErrorholds the code catalog (code, default status, default message) and renders{"error": {"code", "message", "details", "request_id"}}with all four keys always present.FallbackController,ErrorJSON(exceptions: code from status, generic message),TenantAuth,ConvergerAuth(now distinguishingtoken_expired),AdminAuth,RateLimit(details.retry_after_ms),DbUnavailable,SocketGuardrefusals and the controller-specific errors all use it. HTTP statuses are unchanged. Codes shared with Protocol v1 (bad_request,invalid_watermark,invalid_message,unauthorized,token_expired,forbidden,channel_inactive,rate_limited,payload_too_large,conversation_closed,internal,unavailable) are spelled the same; a test enforces it. - Versioning. Breaking changes get a new URL prefix (
/api/v2); additive changes (fields, endpoints, enum values, error codes) ship without one. A deprecated endpoint sendsDeprecation: @<unix>(RFC 9745) andLink: rel="deprecation", laterSunset: <HTTP-date>(RFC 8594) andrel="sunset", viaConvergerWeb.Plugs.Deprecated, and is supported for at least 12 months after the deprecation is announced.
Consequences
Positive
- SDKs generate types from
priv/static/openapi.json, including a singleErrorResponse/ErrorCode. - Clients branch on one
codefield for REST and WebSocket errors, and can quoterequest_id. - A response that does not match its schema, a route without an operation or an uncommitted spec change fails CI.
Negative and trade-offs
- Breaking for clients that parsed error bodies (
{"error": "..."},{"errors": {...}},code: "Unauthorized"). Statuses did not change. This ships insidev1as a one-time alignment before the SDKs exist, documented in errors; later error-shape changes follow the versioning policy. - open_api_spex is a 3.0 library: the 3.1 export is a small rewrite we own, and tests validate with 3.0 semantics.
- Every new endpoint needs an annotation and a regenerated, committed document.
Follow-ups
- #46, #47: SDKs generate from the document.
- Set
Sunsetdates for the legacy surfaces of #23. - Optionally validate requests at runtime with
OpenApiSpex.Plug.CastAndValidate(today only responses are checked, in tests).
Implementation
lib/converger_web/api_error.ex,api_schemas.ex,api_spec.ex,plugs/api_docs.ex,plugs/deprecated.ex,lib/mix/tasks/converger.openapi.ex,priv/static/openapi.json.- Config:
config :converger, :api_docs, enabled:;API_DOCS_ENABLEDinconfig/runtime.exs. - Tests:
test/converger_web/api_contract_test.exs(every operation,assert_api_response/1),api_error_envelope_test.exs(every error path,assert_api_error/3),api_spec_test.exs(drift, refs, 3.1, Swagger UI),plugs/deprecated_test.exs.
Links
- Errors, Versioning and deprecation policy, OpenAPI and Swagger UI, REST API overview
- Protocol v1, section 12: errors
- RFC 9745 (Deprecation header), RFC 8594 (Sunset header)