The API reference, generated from the code in a test

The OpenAPI document is generated from the application in a test, completed with what the code enforces and with recorded responses, and rendered by the site. springdoc never ships.

Status
Accepted 3 October 2026
The record
0013-api-reference-generated-from-the-code.md

Context

The API is public (ADR 0006): the lab's pages call it, and so can anyone. A reference written by hand drifts from the code the first time an endpoint changes. One served by the application, springdoc's /v3/api-docs with Swagger UI, ships a library, the Jackson 2 it brings and endpoints of its own in a service that scales to zero and counts its startup (ADR 0009), and a third-party interface the Content-Security-Policy would have to let in. And the facts a client needs most, the errors an operation answers with, its rate limit and the version header, are not in the controller signatures springdoc reads.

Decision

  • Generated in a test, not served. springdoc-openapi is a test dependency. ApiReferenceTest starts the application, takes springdoc's document and compares it with web/src/content/openapi.json, failing while they differ; ./gradlew exportApiReference writes the file. Each endpoint is described where it is declared, with @Tag, @Operation and @Parameter, annotations that are compile-only and absent from the jar.
  • What the code enforces, added from the code. Refinements in the test complete the document: the RFC 9457 problem and each operation's codes by status, the shared ones derived from the version header, the rate limit and each parameter's constraints, and the particular ones listed, where an operation missing from the list fails the export; the API-Version header from the configured versioning; each limited operation's policy and the draft-ietf-httpapi-ratelimit-headers-11 headers from app.rate-limit.*; server-sent events as text/event-stream; records as Jackson writes them, every component present and the @Nullable ones nullable; patterns anchored as Bean Validation matches them; nested types named as the code spells them, ProfileReport.Gc.
  • Examples recorded, not written. scripts/record-examples.mjs saves one real response per operation from a running API. The test cuts long arrays and long streams short and says so in the example, checks every example against the schema the code generates, so a field added or renamed fails, and replays the recorded problem against the application.
  • Rendered by the site. /api-reference/ renders the document with the site's own components, prerendered like every page and without third-party scripts; the document itself is published at /api-reference/openapi.json for tools.
  • Versioned by header, limited in the open. Spring Framework 7's API versioning reads API-Version, version 1 when it is absent and the only one supported; every response to a limited operation says where the client stands in its policy.

Consequences

  • A new endpoint, a changed parameter, a new code in the list or a changed limit shows up as a diff of openapi.json in the same change; one that is not exported fails the build.
  • Generating it found two schemas that had been merged by name: the profile's and the telemetry sample's Gc, the ledger's and the outbox's Verdict. A reference served by springdoc as it comes would have described one with the other's fields, and read as right.
  • Recordings age. A response that changes shape fails the schema check until record-examples.mjs runs again against a current build, which takes Docker, the lab's PostgreSQL and about two and a half minutes.
  • The particular codes are a list kept by hand and checked for completeness, not derived: a code path that starts answering with a new code is caught by review, not by the test.
  • OpenAPI 3.1 cannot describe the items of a stream (3.2 can), so the events are named in each operation's description and shown in a recorded stream.