Plan: OpenAPI Contract Hygiene — Query-Param Location + Response Annotations

On this page
NOTE

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 #[into_params(parameter_in = Query)] to all 34 IntoParams query structs (verify each is a Query<…> extractor first).

Done (2026-06-04) — !488; 34 structs annotated, 14 snapshots regenerated.

#633 — response/request_body annotations

2

Reconcile per-service #[utoipa::path] response/request_body gaps — verify each code is actually emitted before declaring it.

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) — parameter_in = Query Part B.

shared — regen + guard

3

Bring up the full devstack, cargo xtask api-docs --update, commit the regenerated 15 snapshots.

Done (2026-06-04) — regenerated in both !488 (#593) and the #633 MR.

4

(optional) Strengthen the api-docs drift check to flag in:path on Query<…> handlers (#593 ask 3).

Deferred (#593) — snapshots-committed-and-checked gate already prevents regression; tracked as optional follow-up.

5

CHANGELOG + GitLab issue updates.

In progress

Issues: #593, #633
Branches: fix/593-intoparams-query-location (Step 1), chore/633-utoipa-response-annotations (Step 2). Ship Step 1 first (#593 notes #587 paging will trip over the drift otherwise), then Step 2; each ends with its own api-docs --update regen (Step 3 applies to whichever lands).

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, because utoipa::IntoParams does not default a query location and the structs carry no parameter_in. Generated typed clients then emit positional path segments (/X) instead of ?field=X and 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 = Query on all 34 IntoParams query structs.

  • #633: the enumerated per-service response/request_body annotation 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]/IntoParams only.

  • 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.

Table 1. 34 IntoParams structs to annotate (verified against main)
File Structs

crates/canopy-common/src/pagination.rs

1 (PageRequest)

crates/canopy-contracts-appeals/src/appeals.rs

1

crates/canopy-contracts-appeals/src/ipv.rs

1

crates/canopy-contracts-applications/src/applications.rs

1

crates/canopy-contracts-notices/src/notices.rs

1

crates/canopy-contracts-persons/src/export.rs

1 (optional-field check)

crates/canopy-contracts-persons/src/persons.rs

1

crates/canopy-contracts-reporting/src/overpayments.rs

1

crates/canopy-contracts-rules/src/rule_sets.rs

2 (lines 8, 15)

crates/canopy-contracts-security/src/alerts.rs

1

crates/canopy-contracts-security/src/events.rs

1

crates/canopy-contracts-security/src/export.rs

1 (optional-field check)

crates/canopy-contracts-security/src/fti.rs

1

services/canopy-appeals/src/api/mod.rs

1 (line 298)

services/canopy-caps/src/api/handlers.rs

1 (line 146)

services/canopy-caps/src/api/providers.rs

1 (line 22)

services/canopy-eligibility/src/api/handlers.rs

3 (lines 205, 316, 353)

services/canopy-enrollment/src/api/mod.rs

1 (line 45)

services/canopy-medicaid/src/api/overpayments_handler.rs

1 (line 33)

services/canopy-snap/src/api/abawd_handler.rs

1 (line 20)

services/canopy-snap/src/api/categorical_handler.rs

1 (line 50)

services/canopy-snap/src/api/export.rs

1 (line 36)

services/canopy-snap/src/api/overpayments_handler.rs

1 (line 33)

services/canopy-snap/src/api/params_handler.rs

1 (line 20)

services/canopy-snap/src/api/verification_handler.rs

1 (line 19)

services/canopy-tanf/src/api/overpayments_handler.rs

1 (line 33)

services/canopy-verification/src/api/ievs_discrepancies.rs

1 (line 19)

services/canopy-verification/src/api/verifications.rs

1 (line 38)

services/canopy-wic/src/api/appointment_handlers.rs

1 (line 27)

services/canopy-wic/src/api/handlers.rs

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

NOTE

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.rsApiError 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):

  1. 403 sweep (the dominant fix) — for every #[utoipa::path] whose handler calls a require_* 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.

  2. Genuine non-403 gaps (verified emitted):

    • canopy-enrollment POST /enrollments/{id}/terminate — add the missing request_body = TerminateEnrollmentRequest + 404 (handler ok_or_else(ApiError::NotFound)).

    • canopy-notices generate_notice — add 500 (Typst render/assembly failure → ApiError::internal); resend_notice — add 400 ("notice has no PDF to deliver", ApiError::BadRequest).

    • canopy-applications record_determination — add 404 (application/program not found).

    • canopy-eligibility post_determine — add 400 ("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 --updategit diff docs/modules/ROOT/openapi/ should show in:pathin: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.

Step 5: CHANGELOG + issue updates

  • CHANGELOG.adoc — one entry under == Unreleased.

  • Update #593 (correct "3+ endpoints" → 34 structs; link this plan) and #633 (link this plan; note the persons-export item is closed by Part A).

Files Touched

File Change

30 files in 34 IntoParams structs to annotate (verified against main)

#[into_params(parameter_in = Query)] (+ optional-field fix on 2 export structs).

services/canopy-{appeals,applications,enrollment,medicaid,notices,persons,renewals,reporting,eligibility}/src/api/…

Response / request_body annotation reconciliation per #633 worklist.

docs/modules/ROOT/openapi/*.json

Regenerated snapshots (the regression artifact).

xtask/src/cmd/api_docs.rs

(optional) in:path-on-query drift guard.

CHANGELOG.adoc

Unreleased entry.

Verification

  1. cargo build --workspace — all into_params/utoipa::path attributes parse.

  2. cargo xtask dev start (full) → cargo xtask api-docs --update → inspect git diff docs/modules/ROOT/openapi/: every changed param flips "in": "path""in": "query"; new response codes appear; no unrelated churn.

  3. Re-run cargo xtask api-docs without --update → exits clean (snapshots match live services) = the gate is green.

  4. Spot-check Swagger UI for one affected endpoint (e.g. GET /v1/eligibility/case-status) — parameter shows as query, not path.

  5. 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.

Edit this page · default