Skip to content

fix(aide): generate response schemas with the Serialize contract (#250) - #303

Open
atarutin wants to merge 1 commit into
tamasfe:mainfrom
atarutin:fix/serialize-contract-for-outputs
Open

atarutin wants to merge 1 commit into
tamasfe:mainfrom
atarutin:fix/serialize-contract-for-outputs

Conversation

@atarutin

Copy link
Copy Markdown

Problem

GenContext has one SchemaGenerator (generate.rs, schema field), built with
schemars' default contract, Deserialize. OperationOutput impls such as axum::Json<T>
(axum/outputs.rs) use that same generator, so response schemas say what serde accepts,
not what it produces. Example: Option<T> without skip_serializing_if is always present
in a response, but the response schema marks it optional. TypeScript generated from the
document then has field?: T | null where the right type is field: T | null.

#254 tried two generators and stalled on the question raised in review: what happens when a
type appears on both sides? Both generators emit #/components/schemas/Foo, and a plain
extend lets one silently overwrite the other.

Fix

  • GenContext gets a second generator. schema (inputs) is now explicitly
    for_deserialize(). The new output_schema is for_serialize(). axum::Json and
    axum::Form outputs use output_schema, with matching resolve_output_schema and
    apply_output_transforms methods.
  • The output generator writes its $refs under a private path
    (#/components/x-aide-output-schemas/), so input and output references can always be
    told apart, including references nested inside other components.
  • When the document is finished (ApiRouter::finish_api*), take_merged_definitions merges
    the two sets of definitions:
    • A name is split if its schemas differ, or if either schema refers to a split name.
      This runs to a fixed point, because Outer { inner: Rating } has the same text under
      both contracts but means different things once Rating is split.
    • Names that are not split become one shared component under their usual name.
    • Split names become {Name}-Input and {Name}-Output. FastAPI/Pydantic do the same with
      separate_input_output_schemas.
    • Every $ref in paths, webhooks and components is rewritten to match. If a name
      still collides, generation panics with a clear message instead of overwriting silently.

The result does not depend on route registration order, because both generators are kept
until the end.

Breaking for 0.16: response components gain required entries, and types used on both
sides with differing schemas get new component names. Users who replace ctx.schema with
custom settings should now also set ctx.output_schema.

Tests

crates/aide/src/axum/contract_tests.rs (feature axum-json + axum-query):

  • response_option_without_skip_is_required
  • response_option_with_skip_stays_optional
  • request_option_stays_optional
  • query_option_stays_optional
  • type_on_both_sides_is_split_and_refs_follow_the_side: covers the split, the transitive
    split, and a shared component that is not split
  • every_ref_resolves_and_no_private_path_leaks

Verification

  • On main, with only the test file added, 2 of the 6 tests fail
    (response_option_without_skip_is_required, type_on_both_sides_is_split...).
  • On this branch:
    • cargo test at the workspace root (CI's command) passes 19 lib and 18 doc tests.
    • cargo test -p aide --all-features passes 27 lib and 18 doc tests.
    • cargo fmt --check is clean.
    • cargo clippy -p aide --all-features --all-targets gives the same 9 warnings as main.
  • cargo test -p aide with default features does not compile on main either, because the
    operation.rs tests need macros.

Fixes #250. This builds on the two-generator idea in #254 and answers the review question
there about types used on both sides.

…asfe#250)

Inputs keep schemars' Deserialize contract; OperationOutput impls use a
second generator with the Serialize contract. Components whose schema is
the same under both contracts are shared; those that differ (directly or
through a referenced component) become {Name}-Input and {Name}-Output.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4BPYPoWhLCi3UBiwPRuYi
Sign up for free to 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.

Use schemars Serialization contract for OperationOutput

1 participant