Upgrade utoipa 4 → 5, clearing proc-macro-error - #389
Open
jhodapp wants to merge 3 commits into
Open
Conversation
Contributor
Greptile SummaryThe PR upgrades utoipa from version 4 to 5 while repairing and strengthening the generated OpenAPI contract.
Confidence Score: 5/5The PR appears safe to merge because the previously reported contract defects are corrected and no blocking failure remains. No blocking failure remains.
|
| 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. |
Reviews (4): Last reviewed commit: "docs(openapi): declare the GPL v3.0 lice..." | Re-trigger Greptile
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
force-pushed
the
chore/upgrade-utoipa-5
branch
from
August 13, 2026 15:40
a7f399f to
ef3ede0
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 viautoipa-gen 4.3.1.utoipa-gen 5.xdrops the dependency entirely, so the fix is the utoipa 4 -> 5 upgrade.With both PRs merged,
cargo buildemits 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 = Uuidalias (entity/src/lib.rs:46) then breaks 84 fields, because proc-macros cannot resolve type aliases: the derive sees the tokenId, does not recognize it, and demands aToSchemaimpl that does not exist.Rather than annotate all 84 sites with
#[schema(value_type = ...)], this usesutoipa-config, which upstream documents as the designated mechanism for exactly this (utoipa/src/lib.rs:53lists "Rust type aliases viautoipa-config" as a headline feature). Two aliases in a 6-linebuild.rsreplace ~84 per-field annotations, andIdstays 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:
$refsmain's spec is substantially broken today: it emits$refs toId,Date,DateTime,Role,Recurrence,Jwtand 39 other schemas that are never defined incomponents/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
maintoo, 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 /notesdocumented as coaching sessions.create/read/updateacross actions, agreements, goals, notes, organizations, plusarchive/unarchive).PUT /goals/{id}/statusandPUT /actions/{id}/statusdeclared an actions request body despite taking noJsonextractor.PUT /users/{user_id}/passwordextractsJson<UpdatePasswordParams>but declared no request body, so clients could not see a body they must send.deleteendpoints declaredbody = [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:
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
build.rs+utoipa-configto the 5 crates deriving utoipa traits, aliasingId->UuidandDateTimeWithTimeZone->DateTime<FixedOffset>chronoandconfigfeatures on utoipa (onlyaxum_extras+uuidwere on)ToSchemaonRole,Recurrence,Frequency,IncludeParam,AssigneeScope(utoipa 5 collects nested schemas and requires them)body =paths throughdomain(utoipa 5 resolves these in scope; utoipa 4 treated them as text)Uuidrather than the macro-unresolvableIdalias#[schema(value_type = ...)]in the 2 places it is genuinely right:semver::Versionandchrono::Weekday, both foreign types utoipa has no support forutoipa-rapidocto=5.0.0, the only release pairing utoipa 5 with axum 0.7 (5.0.1+ requires axum 0.8)web/src/router_tests.rswith two guards: the spec has no dangling$refs, and everyrequest_bodyannotation matches its handler's signature (declared implies aJsonextractor, and vice versa)Testing Strategy
cargo test -p entity_api -p domain -p web --features "domain/mock,web/mock"-> 769 passed, 0 failedcargo clippy --workspace --all-targetsclean, and clean again under the mock featurescargo fmt --all --checkcleancargo tree -i proc-macro-errorreports no such packageApiDoc::openapi()on bothmainand this branch and diffed structurally: no path or schema lost, dangling refs 45 -> 0What the regression tests do and do not cover
Enforced: no dangling
$refs, andrequest_bodyannotations 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
utoipa-configis0.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 inOUT_DIRand can go stale, so acargo cleanmay be needed if the aliases are ever edited.build.rsto 5 crates, which is a small build-time cost per crate.mainand does not include it.