Skip to content

feat(api): describe response schemas in the OpenAPI document - #38

Merged
DutchyD merged 2 commits into
developmentfrom
feat/openapi-response-schemas
Aug 10, 2026
Merged

feat(api): describe response schemas in the OpenAPI document#38
DutchyD merged 2 commits into
developmentfrom
feat/openapi-response-schemas

Conversation

@DutchyD

Copy link
Copy Markdown
Contributor

The frontend wants to generate its client from this service's contract rather than hand-writing it. It could not: the document described five paths and no shapes at all.

components.schemas: []
GET /v1/site => 200: (no content)
GET /v1/projects => 200: (no content)
GET /v1/projects/{slug} => 200: (no content)

Every endpoint returns Task<IResult>, which is opaque to the schema generator, and nothing declared a response type. A generated client would have given every endpoint unknown.

What changed

Responses are declared..Produces<T>() and .ProducesProblem(...) on each route, with the statuses each endpoint's code paths can actually produce — GetProject gets 404, TriggerRefresh gets 401, /diagnostics gets neither, and the four cacheable reads get 304.

This is deliberately not a TypedResults refactor. Loom.Results categories map through ToHttpResult() at one point, and "a slice never writes a status code" is a rule here; TypedResults would have pushed status decisions back into handlers. The cost is honest: Produces<T> is a claim the compiler does not check.

Schema ids are qualified by operation. Every slice names its response type Response, and components.schemas is a flat namespace, so all five collapsed onto one schema — the site's. CreateSchemaReferenceId now yields GetSiteResponse, ListProjectsResponse and so on.

Two facts the generator cannot infer are supplied by a schema transformer.

  • Optional scalars are omitted, not null. The generator marked every nullable property required, contradicting DefaultIgnoreCondition = WhenWritingNull and this repo's own rule. A consumer would have typed name: string | null when the key is simply absent.
  • Enums reach the wire as strings.status, role, type, kind, source, severity and outcome generated as bare string. They now list their members, taken from the enum each property declares via [WireEnum(typeof(...))] — next to the field rather than in a central table, so nothing has to be kept in sync by hand. Relation names come from RelationVocabulary itself.

Per-property naming is load-bearing: SiteLinkType.GitHub is github under Wire.Lower but git-hub under Wire.Hyphenate, so one rule for all of them would be wrong.

Wire gained a Lower(string) overload so the transformer does not lowercase inline.

The document is committed, and CI fails when it drifts. A consumer cannot run this service to obtain it. scripts/openapi.sh regenerates it; CI regenerates and diffs, so a wire type cannot change without the contract moving with it.

Build-time generation via Microsoft.Extensions.ApiDescription.Server was tried and rejected: the generator runs the composition root, which validates options on start, so dotnet build began failing on a clean checkout without a RefreshKey.

error : DataAnnotation validation failed for 'ApiOptions' members:
'RefreshKey' with the error: 'The RefreshKey field is required.'

servers is stripped from the artifact — it records whichever port the generating run used. Output verified byte-identical across two different ports, so the drift check cannot fire spuriously.

Result

schemas: 21
properties carrying enum values: 12
nullable properties still marked required: 0

Verification

dotnet build 0 warnings under TreatWarningsAsErrors, dotnet format --verify-no-changes clean, 364/364 tests pass including ArchitectureTests. The wire records stayed internal.

The drift check was tested by adding a field to SiteLinkView and confirming CI's comparison goes red, then reverting. Its first version used a bare git diff --exit-code, which passes silently while the file is untracked; it now runs git add --intent-to-add first, so a deleted or untracked document fails too.

Every endpoint returned IResult, which the schema generator cannot read, so
the document described five paths and no shapes. Declaring the responses
turns it into a contract a client can be generated from.
Two facts the generator still cannot infer are supplied by a schema
transformer: optional scalars are omitted rather than sent as null, so a
nullable property is not required; and an enum reaches the wire as a string,
so its members are listed from the enum a property declares.
A consumer generating a client cannot run this service to obtain the
document, so it is committed. Build-time generation is not an option: the
generator runs the composition root, which validates options on start and
so needs a refresh key to produce a document.
The script boots the built assembly instead and reads the document over
HTTP. servers is stripped, or the artifact would record whichever port the
generating run used.
@DutchyD
DutchyD merged commit a393667 into developmentAug 10, 2026
5 checks passed
@DutchyD
DutchyD deleted the feat/openapi-response-schemas branch August 10, 2026 10:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@DutchyD