Plan: OpenAPI Contract Hygiene — Query-Param Location + Response Annotations
On this page
Authored from a code-grounded inventory, not the issue text. #593 says "3+ endpoints"; the real count is 34 IntoParams structs across 14 contract-crate files + 20 service files (see 34 IntoParams structs to annotate (verified against main)). The issue’s suggested grep (services//src/api/) misses the 14 contract-crate structs entirely. *#633 overlaps #593 on the canopy-persons export struct (it independently flags the same in:path mislabel), and both issues regenerate the same docs/modules/ROOT/openapi/*.json snapshots — so they are one plan to avoid two rounds of snapshot churn.
Status
| Step | Description | Status |
|---|---|---|
#593 — query-param location |
||
1 |
Add |
Done (2026-06-04) — !488; 34 structs annotated, 14 snapshots regenerated. |
#633 — response/request_body annotations |
||
2 |
Reconcile per-service |
Done (2026-06-04) — 403 sweep (~197 ops, 15 services) + genuine non-403 gaps; issue’s bogus 409/422/502 dropped. See Part A (#593) — |
shared — regen + guard |
||
3 |
Bring up the full devstack, |
Done (2026-06-04) — regenerated in both !488 (#593) and the #633 MR. |
4 |
(optional) Strengthen the api-docs drift check to flag |
Deferred (#593) — snapshots-committed-and-checked gate already prevents regression; tracked as optional follow-up. |
5 |
CHANGELOG + GitLab issue updates. |
In progress |
Context
The published OpenAPI snapshots in docs/modules/ROOT/openapi/{service}.json are the machine-readable ATO API-reference artifact (#265) and the source for the Antora per-service API pages. Two independent audits found the snapshots under-/mis-describe the real surface:
-
#593 (external audit finding #7) — handlers that take
Query<SomeStruct>parameters emit"in": "path"in the spec, becauseutoipa::IntoParamsdoes not default a query location and the structs carry noparameter_in. Generated typed clients then emit positional path segments (/X) instead of?field=Xand 404 at runtime; Swagger UI documents the wrong call shape. Axum routing ignores the annotation, so it is not a runtime bug for canopy itself — it is a contract bug for every downstream consumer. -
633 (surfaced by the #620 API-page refresh) — several handlers emit error/response codes (and one accepts a request body) that their
[utoipa::path]decorators don’t declare, so the spec under-describes the surface. The #620 prose already describes the true behavior; this is the source-side decorator reconciliation that makes the JSON match.
Grouped because they share the regeneration pipeline and overlap (the canopy-persons export struct appears in both), and folding them yields one coherent snapshot diff to review instead of two overlapping ones.
Scope
In scope:
-
#593:
parameter_in = Queryon all 34IntoParamsquery structs. -
#633: the enumerated per-service response/
request_bodyannotation gaps — each verified against the handler’s actual emit before being added (or the prose corrected if the code is not emitted). -
One regeneration of the 15 committed OpenAPI snapshots per landed MR.
-
Optionally, a stronger drift check (Step 4).
Out of scope:
-
Doc prose — already corrected by 620. This is source-side
[utoipa::path]/IntoParamsonly. -
Changing any runtime behavior, handler logic, route, or actual status code emitted. This plan only makes the declared contract match the emitted contract.
-
Adding new endpoints or new query parameters.
Design
Part A (#593) — parameter_in = Query
utoipa 5.4 accepts a container attribute [into_params(parameter_in = Query)] directly under the [derive(… IntoParams)]. Applied to a struct, every field becomes a query parameter. Confirmed pattern (verified on main): every one of these structs is consumed via a Query<T> axum extractor and referenced in a #[utoipa::path(… params(T))] — e.g. PageRequest (crates/canopy-common/src/pagination.rs:6, Query<PageRequest>) and HouseholdQuery (services/canopy-snap/src/api/categorical_handler.rs:50, Query<HouseholdQuery> + params(HouseholdQuery)). None of the 34 currently carry parameter_in (workspace grep for parameter_in returns zero hits).
Procedure per struct: confirm the handler uses Query<T> (the expected case for all 34), then add the one-line container attribute:
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)] // <- add this line
pub struct HouseholdQuery { … }
If any struct turns out to aggregate path segments instead (none found in the spot-check — canopy captures {id} via Path<…> extractors, not IntoParams), leave it as Path / annotate parameter_in = Path explicitly and note it in the MR.
Optional-vs-required (the 633 persons-export overlap): parameter_in = Query fixes in:path, but a field still renders required: true unless it is Option<T> or carries [param(required = false)]. The canopy-persons + canopy-security export structs (crates/canopy-contracts-persons/src/export.rs:21, crates/canopy-contracts-security/src/export.rs:16) are flagged by 633 as optional query params mislabeled required. Where an export filter field is semantically optional, make it Option<T> (preferred — it also fixes the deserialize contract) or add [param(required = false)]. Resolve this here so #633’s persons-export item is fully closed by Part A.
| File | Structs |
|---|---|
|
1 ( |
|
1 |
|
1 |
|
1 |
|
1 |
|
1 (optional-field check) |
|
1 |
|
1 |
|
2 (lines 8, 15) |
|
1 |
|
1 |
|
1 (optional-field check) |
|
1 |
|
1 (line 298) |
|
1 (line 146) |
|
1 (line 22) |
|
3 (lines 205, 316, 353) |
|
1 (line 45) |
|
1 (line 33) |
|
1 (line 20) |
|
1 (line 50) |
|
1 (line 36) |
|
1 (line 33) |
|
1 (line 20) |
|
1 (line 19) |
|
1 (line 33) |
|
1 (line 19) |
|
1 (line 38) |
|
1 (line 27) |
|
2 (lines 175, 180) |
Re-grep before starting to catch drift since this plan was written: grep -rn "derive.IntoParams" --include='.rs' crates/ services/. Line numbers will move; the file list is the durable anchor.
Part B (#633) — response / request_body annotations
A code-grounded re-verification (2026-06-04) found the #633 issue body is mostly wrong — it guesses 409/422/502 codes that the handlers do not emit, and under-states the one gap that is pervasive: undeclared 403. The canonical map is crates/canopy-common/src/error.rs — ApiError has variants for 400/401/403/404/409/422/500 and no 502/BadGateway/ServiceUnavailable variant at all. So every "502" item is impossible. 401 is emitted by the shared auth middleware (crates/canopy-auth/src/middleware.rs), not by handlers. The dominant real gap: every require_* guard returns ApiError::Forbidden (403), and most operations never declared it.
Corrected worklist (what was actually implemented):
-
403 sweep (the dominant fix) — for every
#[utoipa::path]whose handler calls arequire_*guard, declare(status = 403, …). Applied across all 15 JSON services (~197 additions); pre-existing 403s and non-guarded ops (healthz/metrics/internal adapter callbacks) left alone. -
Genuine non-403 gaps (verified emitted):
-
canopy-enrollment
POST /enrollments/{id}/terminate— add the missingrequest_body = TerminateEnrollmentRequest+404(handlerok_or_else(ApiError::NotFound)). -
canopy-notices
generate_notice— add500(Typst render/assembly failure →ApiError::internal);resend_notice— add400("notice has no PDF to deliver",ApiError::BadRequest). -
canopy-applications
record_determination— add404(application/program not found). -
canopy-eligibility
post_determine— add400("household has no members",orchestrator.rs).
-
Dropped as not-emitted (issue is wrong): medicaid 422 + the GET /determinations household/person filter claim (handler takes no query params); persons 409 (no Conflict/ON CONFLICT anywhere); renewals 409 (no duplicate-active guard); reporting 502 (no variant) + the status param required+nullable claim (it is already Option<String>, renders optional). These were verified against the code, not assumed.
Regeneration (shared)
cargo xtask api-docs --update queries each running service at http://localhost:{host_port}/api-doc/openapi.json and overwrites docs/modules/ROOT/openapi/{short}.json (xtask/src/cmd/api_docs.rs). So regen requires the full devstack up — a partial stack regenerates partial/empty snapshots. The offline drift check (no --update) is the pre-push regression gate; it fails if a committed snapshot doesn’t match the live service, which is exactly what catches a missed annotation.
Steps
Step 1: annotate query structs (#593)
Files: the 30 files in 34 IntoParams structs to annotate (verified against main) (34 structs).
Add [into_params(parameter_in = Query)] under each [derive(…IntoParams)]. For the two export structs, also make optional filter fields Option<T> / #[param(required = false)]. cargo build -p <crate> per touched crate to confirm the attribute parses.
Step 2: response/request_body reconciliation (#633)
Files: the #[utoipa::path] decorators on the handlers named in Part B (appeals/applications/enrollment/medicaid/notices/persons/renewals/reporting/eligibility src/api/).
For each worklist item: read the handler, confirm the status code is emitted (grep the handler body / its ?-propagated ApiError variants), then add the responsesstatus = N, description = "…" tuple or request_body = T. If a listed code is not emitted, leave the decorator and instead note it (the #620 prose may need a follow-up). Do not invent codes.
Step 3: regenerate + commit snapshots
Files: docs/modules/ROOT/openapi/*.json
cargo xtask dev start (full profile) → wait healthy → cargo xtask api-docs --update → git diff docs/modules/ROOT/openapi/ should show in:path→in:query flips (Step 1) and new response codes (Step 2), nothing else. Commit the regenerated snapshots in the same MR as the source change that produced them.
Step 4 (optional): strengthen the drift guard (#593 ask 3)
Files: xtask/src/cmd/api_docs.rs
#593 asks for a check that asserts no in:path schema on a Query<> handler. A full handler-signature cross-check is heavy; a pragmatic guard is a snapshot-level assertion that flags any parameter object with "in": "path" whose name is not present as a {name} segment in the operation’s path (a path param’s name must appear in the URL template; a mislabeled query param’s will not). Implement as an extra pass in run() when !update. Mark this step Deferred (xref:plans/api-contract-hygiene.adoc) if it grows beyond ~1 screen — the snapshots-committed-and-checked gate already prevents regression once Steps 1-3 land.
Files Touched
| File | Change |
|---|---|
30 files in 34 |
|
|
Response / |
|
Regenerated snapshots (the regression artifact). |
|
(optional) |
|
Unreleased entry. |
Verification
-
cargo build --workspace— allinto_params/utoipa::pathattributes parse. -
cargo xtask dev start(full) →cargo xtask api-docs --update→ inspectgit diff docs/modules/ROOT/openapi/: every changed param flips"in": "path"→"in": "query"; new response codes appear; no unrelated churn. -
Re-run
cargo xtask api-docswithout--update→ exits clean (snapshots match live services) = the gate is green. -
Spot-check Swagger UI for one affected endpoint (e.g.
GET /v1/eligibility/case-status) — parameter shows as query, not path. -
cargo xtask validate— fmt + clippy + docker build clean.
Documentation Updates
-
CHANGELOG.adoc— Unreleased entry. -
docs/modules/ROOT/openapi/.json— regenerated (this *is the doc artifact). -
Antora per-service API pages already match (the #620 prose described the true surface); no prose change expected — confirm a sample page still renders correctly post-regen.
-
GitLab #593 / #633 — link this plan; correct #593’s count.