Skip to content

Upgrade utoipa 4 → 5, clearing proc-macro-error - #389

Open
jhodapp wants to merge 3 commits into
mainfrom
chore/upgrade-utoipa-5
Open

Upgrade utoipa 4 → 5, clearing proc-macro-error#389
jhodapp wants to merge 3 commits into
mainfrom
chore/upgrade-utoipa-5

Conversation

@jhodapp

@jhodapp jhodapp commented Aug 13, 2026

Copy link
Copy Markdown
Member

Description

Follow-on to #388. That PR removed proc-macro-error2; this one removes the other unmaintained proc-macro crate, proc-macro-error 1.0.4 (last released 2021), which reaches us via utoipa-gen 4.3.1. utoipa-gen 5.x drops the dependency entirely, so the fix is the utoipa 4 -> 5 upgrade.

With both PRs merged, cargo build emits no future-incompatibility warnings.

The interesting part

utoipa 5 removed the silent fallback that let unknown field types compile as untyped objects. Our pub type Id = Uuid alias (entity/src/lib.rs:46) then breaks 84 fields, because proc-macros cannot resolve type aliases: the derive sees the token Id, does not recognize it, and demands a ToSchema impl that does not exist.

Rather than annotate all 84 sites with #[schema(value_type = ...)], this uses utoipa-config, which upstream documents as the designated mechanism for exactly this (utoipa/src/lib.rs:53 lists "Rust type aliases via utoipa-config" as a headline feature). Two aliases in a 6-line build.rs replace ~84 per-field annotations, and Id stays a real abstraction instead of having "an Id is a Uuid" hardcoded in 84 places.

The spec gets materially more correct

The upgrade was verified by generating the full OpenAPI document before and after and diffing it:

before after
dangling $refs 45 of 74 0 of 50
paths 68 68 (none lost)
schemas 43 51 (none lost, 8 gained)

main's spec is substantially broken today: it emits $refs to Id, Date, DateTime, Role, Recurrence, Jwt and 39 other schemas that are never defined in components/schemas. Any consumer doing codegen against it hits unresolvable references. That is now zero, and a regression test enforces it.

Annotation bugs this surfaced (a7f399f)

Making the refs resolve exposed that some annotations were also simply wrong. They were wrong on main too, but pointed at unregistered schemas, so nothing consumed them; now they would publish a wrong contract. Found by auditing all 96 annotated handlers against their signatures and return expressions:

  • GET /agreements/{id} documented as an array of notes; it returns one agreement. GET /notes documented as coaching sessions.
  • 21 single-returning handlers documented as arrays (create/read/update across actions, agreements, goals, notes, organizations, plus archive/unarchive).
  • PUT /goals/{id}/status and PUT /actions/{id}/status declared an actions request body despite taking no Json extractor.
  • PUT /users/{user_id}/password extracts Json<UpdatePasswordParams> but declared no request body, so clients could not see a body they must send.
  • The three delete endpoints declared body = [i32] but return {"id": <uuid>}.

Breaking change for API consumers

The spec version moves 3.0.3 -> 3.1.0, changing how nullable fields are encoded:

// before (3.0.3)          // after (3.1.0)
{"type": "string",         {"type": ["string", "null"]}
 "nullable": true}

Schema names are also now fully qualified (Model -> entity.goals.Model), and UUID fields resolve to {"type":"string","format":"uuid"} instead of a dangling $ref. This needs frontend coordination if anything generates types from the spec.

Changes

  • Add build.rs + utoipa-config to the 5 crates deriving utoipa traits, aliasing Id -> Uuid and DateTimeWithTimeZone -> DateTime<FixedOffset>
  • Enable the chrono and config features on utoipa (only axum_extras + uuid were on)
  • Derive ToSchema on Role, Recurrence, Frequency, IncludeParam, AssigneeScope (utoipa 5 collects nested schemas and requires them)
  • Qualify response body = paths through domain (utoipa 5 resolves these in scope; utoipa 4 treated them as text)
  • Declare UUID path/query params as Uuid rather than the macro-unresolvable Id alias
  • #[schema(value_type = ...)] in the 2 places it is genuinely right: semver::Version and chrono::Weekday, both foreign types utoipa has no support for
  • Pin utoipa-rapidoc to =5.0.0, the only release pairing utoipa 5 with axum 0.7 (5.0.1+ requires axum 0.8)
  • Add web/src/router_tests.rs with two guards: the spec has no dangling $refs, and every request_body annotation matches its handler's signature (declared implies a Json extractor, and vice versa)

Testing Strategy

  • cargo test -p entity_api -p domain -p web --features "domain/mock,web/mock" -> 769 passed, 0 failed
  • cargo clippy --workspace --all-targets clean, and clean again under the mock features
  • cargo fmt --all --check clean
  • cargo tree -i proc-macro-error reports no such package
  • OpenAPI document generated from ApiDoc::openapi() on both main and this branch and diffed structurally: no path or schema lost, dangling refs 45 -> 0

What the regression tests do and do not cover

Enforced: no dangling $refs, and request_body annotations consistent with handler signatures (verified to fail, not pass vacuously, when a phantom body is reintroduced).

Not enforced: response model identity and array-vs-single shape. Nothing in the OpenAPI document knows what a Rust handler returns, so these cannot be machine-checked from the spec alone. They were audited by hand this pass. A committed spec snapshot would close the gap by making contract drift a reviewable diff; worth doing as a follow-up.

Concerns

  • The 3.0.3 -> 3.1.0 spec bump is the real risk here, not the Rust changes. Worth confirming with the frontend before merge if any client types are generated from the spec.
  • utoipa-config is 0.1.2, last published Oct 2024, while utoipa itself is on 5.5.0 (May 2026). It is stable and still exercised in-tree by upstream (utoipa-config/config-test-crate/), but it is a quiet crate. Its README also warns the config is cached in OUT_DIR and can go stale, so a cargo clean may be needed if the aliases are ever edited.
  • This adds a build.rs to 5 crates, which is a small build-time cost per crate.
  • Recommend merging Drop proc-macro-error2 via sea-bae 0.2.2 #388 first; this branch is cut from main and does not include it.

@jhodapp jhodapp self-assigned this Aug 13, 2026
@greptile-apps

greptile-apps Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR upgrades utoipa from version 4 to 5 while repairing and strengthening the generated OpenAPI contract.

  • Adds shared utoipa alias configuration for UUID and timezone-aware datetime types.
  • Corrects response shapes, response model identities, and request-body annotations surfaced by the upgrade.
  • Adds regression checks for dangling schema references and request-body/handler-signature consistency.
  • Removes the obsolete proc-macro-error dependency path.

Confidence Score: 5/5

The PR appears safe to merge because the previously reported contract defects are corrected and no blocking failure remains.

No blocking failure remains.

Important Files Changed

Filename Overview
Cargo.lock Updates the utoipa dependency family, adds utoipa-config, and removes proc-macro-error without changing the flagged pre-existing advisory versions.
web/src/router_tests.rs Adds focused checks for unresolved OpenAPI references and request-body annotations; the test's documented scope matches the current claim.
web/src/controller/agreement_controller.rs Corrects the agreement response model and single-object response shape reported in the previous review.
web/src/controller/note_controller.rs Corrects the notes collection schema and single-note response shapes reported in the previous review.
web/src/controller/goal_controller.rs Removes the phantom status-update request body and corrects single-goal response shapes.
web/src/controller/user/password_controller.rs Documents the required password-update request body to match the handler's JSON extractor.
domain/build.rs Configures utoipa aliases for shared identifier and timezone-aware datetime types.
web/build.rs Applies the same utoipa alias configuration while generating web-crate schemas.

Fix All in Greploop

Reviews (4): Last reviewed commit: "docs(openapi): declare the GPL v3.0 lice..." | Re-trigger Greptile

Comment thread web/src/controller/agreement_controller.rs Outdated
Comment thread web/src/controller/goal_controller.rs Outdated
Comment thread web/src/router_tests.rs
@jhodapp jhodapp changed the title chore(deps): upgrade utoipa 4 → 5, clearing proc-macro-error Upgrade utoipa 4 → 5, clearing proc-macro-error Aug 13, 2026
@jhodapp jhodapp added the enhancement Improves existing functionality or feature label Aug 13, 2026
@jhodapp jhodapp added this to the 1.0.0-beta3 milestone Aug 13, 2026
utoipa-gen 4.3.1 depends on proc-macro-error 1.0.4, unmaintained since
2021. utoipa-gen 5.x drops it entirely.

utoipa 5 removed the silent fallback that let unknown field types through,
so aliased types now need resolving. Rather than annotate ~84 Id fields
individually, this uses utoipa-config (upstream's designated mechanism for
Rust type aliases) to teach the derive macros that Id is a Uuid and
DateTimeWithTimeZone is a DateTime<FixedOffset>.

The generated spec is materially more correct: dangling $refs drop from
45 of 74 to zero, and 8 nested schemas that utoipa 4 referenced but never
registered are now defined.

Note the spec version moves 3.0.3 -> 3.1.0, which changes how nullable
fields are encoded ("nullable": true becomes type: [T, "null"]).

- add build.rs + utoipa-config to the 5 crates deriving utoipa traits
- derive ToSchema on Role, Recurrence, Frequency, IncludeParam, AssigneeScope
- qualify response body paths through domain (utoipa 5 resolves them in scope)
- declare UUID path/query params as Uuid instead of the unresolvable Id alias
- pin utoipa-rapidoc =5.0.0, the only release pairing utoipa 5 with axum 0.7
- add a regression test asserting the spec has no dangling $refs
…blishes

These annotations were already wrong on main, but they pointed at
unregistered schemas, so the errors were invisible. Now that refs resolve,
they publish a wrong contract, so fix them here.

- GET /agreements/{id} was documented as an array of notes; it returns one
  agreement. GET /notes was documented as coaching sessions.
- 21 single-returning handlers were documented as arrays.
- PUT /goals/{id}/status and PUT /actions/{id}/status declared an actions
  request body despite having no Json extractor.
- PUT /users/{user_id}/password extracts UpdatePasswordParams but declared
  no request body.
- The three delete endpoints declared [i32] but return {"id": uuid}.

Also extends the regression guard: request_body annotations are now checked
against handler signatures, so a declared body with no Json extractor (or the
reverse) fails the build rather than shipping.
@jhodapp
jhodapp force-pushed the chore/upgrade-utoipa-5 branch from a7f399f to ef3ede0 Compare August 13, 2026 15:40
The info block carried only a title, so RapiDoc's overview showed no
license. Points at LICENSE.md, which is the full GPL v3 text.

Uses name + url rather than the SPDX identifier field: the two are
mutually exclusive in OpenAPI 3.1, url renders as a link in RapiDoc, and
the repo does not state whether it is GPL-3.0-only or -or-later.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Improves existing functionality or feature

Projects

Status: Review

Development

Successfully merging this pull request may close these issues.

1 participant