Skip to content

fix(codegen): responses may omit deprecated fields - #218

Merged
antonzherdev merged 5 commits into
mainfrom
fix/python-deprecated-response-fields
Nov 6, 2026
Merged

antonzherdev merged 5 commits into
mainfrom
fix/python-deprecated-response-fields

Conversation

@antonzherdev

@antonzherdev antonzherdev commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

What changes

A schema transform in codegen/schema makes deprecated fields of response-only types optional, and all four generators run it. A client generated while a field was deprecated keeps decoding responses after the server removes the field.

For a deprecated field of a response-only type:

Generator legacy_code: String before After
Python str = Field(deprecated=...): required str | None = Field(default=None, deprecated=...)
TypeScript legacy_code: string legacy_code?: string
Rust client pub legacy_code: String #[serde(default)] pub legacy_code: Option<String>. A field already typed Option<T> keeps its type. Option<T> implements Default for any T, so this compiles for type parameters and for types without Default
OpenAPI listed in required not listed

A response that lacks the field used to fail as a whole (legacy_code Field required). It now decodes, and the field reads as None or undefined.

A type that the Builder splits into input:: and output:: copies counts as response-only on its output:: side, so its requests still require the field.

What stays the same

  • Types also used in requests: a deprecated field stays required, because the server may still require it until it removes it.
  • Types the client decodes by trying them in turn: untagged enums, enums with an untagged variant, the type behind a flattened Option, and every type these reach through fields and generic arguments, including the arguments wherever a reached generic type is used. They are unchanged. A first-match decoder could otherwise pick a different variant than serde, and a flattened Option could decode as Some with defaults where the server meant None.
  • Python re-serializing: a model decoded without the key writes it back without the key.

Changed, not just fixed

TypeScript and Rust code that reads such a field now has to handle its absence: ?: in TypeScript, and Option<T> in Rust. Only deprecated fields are affected. The changelog lists this under Changed.

Testing

  • Field presence fixture, all four generators: a response-only Profile decodes without its deprecated Option, String, struct and NonZeroU32 fields, and a generic Envelope<T> without its deprecated result: T. It's checked in Python and in the Rust client, the TypeScript type accepts { id: 1 }, and the OpenAPI definition requires only id. A deprecated field of the shared Shared type stays required in each of them.
  • Unit test deprecated_fields_of_response_only_types_may_be_absent in codegen/schema/deprecation.rs.
  • Snapshots: in reflectapi_deprecation_dates and reflectapi_deprecated_headers, the output copies gain ?: (TypeScript), #[serde(default)] (Rust) and default=None (Python), and leave OpenAPI's required. That includes a deprecated response header.
  • Locally: fmt, clippy, workspace tests, demo regeneration (no drift), pyright, pytest and the field-presence suites pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q7zEtTUpmxtYWDkuq6uwpY

@antonzherdev
antonzherdev requested a review from hardbyte November 4, 2026 00:45
@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

📖 Documentation Preview: https://reflectapi-docs-preview-pr-218.partly.workers.dev

Updated automatically from commit d2a834d

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

@antonzherdev

Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread reflectapi/src/codegen/python.rs Resolved
A server removes a deprecated field once its sunset passes, and a client
generated while the field was deprecated then failed to decode every
response, although it never read the field. A schema transform in
codegen/schema now makes deprecated fields of response-only types
optional, and every generator runs it: Python reads such a field as None
and types a non-Option field T | None, TypeScript marks it optional, the
Rust client gives it a serde default, and OpenAPI leaves it out of
required.

Types also used in requests keep the field required, since the server
may still require it, and so do untagged enum variants, where an
optional field could change which variant matches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7zEtTUpmxtYWDkuq6uwpY
@antonzherdev
antonzherdev force-pushed the fix/python-deprecated-response-fields branch from 9426d45 to 3c89ab7 Compare November 5, 2026 10:29
@antonzherdev antonzherdev changed the title fix(python): responses may omit deprecated fields fix(codegen): responses may omit deprecated fields Nov 5, 2026
…um reaches

An untagged enum picks the first variant that matches. Only fields
declared on its variants were kept required, so a response-only struct a
variant wraps was still relaxed, and a payload serde gives to a later
variant could match the earlier one. Every type reachable from an
untagged enum's variants, through fields and generic arguments, now
keeps its deprecated fields required.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7zEtTUpmxtYWDkuq6uwpY
@antonzherdev

Copy link
Copy Markdown
Contributor Author

@claude review

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open from earlier reviews (1):

  • Unresolved: 1 blocking on lines changed since (possibly already fixed).

If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🔴 reflectapi/src/codegen/rust.rs — Generated Rust clients stop compiling when a response-only struct has a deprecated field of a primitive without Default, such as url::Url, std::net::IpAddr or std::num::NonZeroU32. type_implements_default returns true for every primitive and type parameter (_ => true). So the relaxed field keeps its bare type and gets #[serde(default = "Default::default")]. Fix: return true only for types known to implement Default, and wrap every other relaxed field in Option<T>, as is already done for generated structs.

    Why this was flagged

    Take a response-only struct with #[deprecated] pub homepage: url::Url. relax_deprecated_response_fields (schema/deprecation.rs:23-31) sets required = false. optional_field_type (rust.rs:1717-1723) keeps the type url::Url because type_implements_default finds url::Url as a Type::Primitive in the typespace and falls to _ => true at rust.rs:1703. render_attributes (rust.rs:1061-1062) then emits #[serde(default = "Default::default")] on a url::Url field. Url has no Default, so the generated client crate fails to compile. On the base branch the field stayed required, so no default attribute was emitted and the client compiled. The fallthrough was effectively unreachable before, because a server-side #[serde(default)] field already needed Default. The new pass makes it reachable for any deprecated field type: url::Url, IpAddr/Ipv4Addr, NonZero*, arrays longer than 32, and generic T, where serde adds a T: Default bound.

    Verification: The pass at deprecation.rs:23-31 sets field.required = false on deprecated required fields without checking their type. For url::Url, type_implements_default reaches _ => true at rust.rs:1703, so default = "Default::default" is emitted and the generated client crate fails to compile. Before this change, such fields came from a server-side #[serde(default)], which already required Default.

  • 🔴 reflectapi/src/codegen/rust.rs — Generated Rust clients stop compiling when a response-only generic struct has a deprecated field typed by its own parameter, such as #[deprecated] result: T. type_implements_default assumes generic parameters implement Default. The field template emits default = "Default::default" (rust.rs:1062), and serde adds no T: Default bound for a path default. So the generated Deserialize impl hits E0277. Fix: treat a bare type parameter (also behind Box, Rc or Arc) as having no Default, so the field becomes Option<T>. Otherwise emit bare #[serde(default)] for it.

    Why this was flagged

    Trigger: a response-only generic envelope such as struct Envelope<T> { data: T, #[deprecated] result: T }, a common rename pattern. relax_deprecated_response_fields (schema/deprecation.rs:23-31) sets result.required = false. optional_field_type (rust.rs:1717-1719) keeps the type as T, because type_implements_default looks up "T", finds no type and returns true (rust.rs:1695-1703, documented at rust.rs:1670-1673). __Field::render_attributes (rust.rs:1061-1062) then writes #[serde(default = "Default::default")] pub result: T. serde_derive adds a T: Default bound only for a bare default, not for default = "path". The struct template (rust.rs:453-460) adds no where clause, so the impl<'de, T: Deserialize<'de>> Deserialize<'de> for Envelope<T> calls Default::default() for an unbounded T and fails with E0277. On the base branch the deprecated field stayed required with no default attribute, and the client compiled.

    Verification: Trigger: a response-only generic struct has a deprecated required field whose type is a bare type parameter. The new pass relax_deprecated_response_fields (schema/deprecation.rs:23-31) never looks at the field's type. For the field type ref named "T", the lookup falls to _ => true (rust.rs:1703). Merging makes the generated Rust client fail to build.

…piling

A tagged enum's untagged variants and the type behind a flattened Option
are decoded by trying them, like untagged enums, so the types they reach
keep their deprecated fields required too. Otherwise a payload could
match an earlier variant, or a flattened group the server left out could
decode as Some with defaults.

A relaxed deprecated field of the Rust client keeps its type only when
the type is known to implement Default. A type parameter or a primitive
such as NonZeroU32 or url::Url becomes Option<T>, where a serde default
would not compile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7zEtTUpmxtYWDkuq6uwpY
@antonzherdev

Copy link
Copy Markdown
Contributor Author

Both Rust Default findings are fixed in 8cd66b3. A relaxed deprecated field keeps its type only when the type is known to implement Default. Otherwise it becomes Option<T>, which covers type parameters, url::Url, NonZeroU32 and similar. The field-presence fixture now has a deprecated NonZeroU32 field and an Envelope<T> with a deprecated result: T, and the generated client compiles and decodes both.

@antonzherdev

Copy link
Copy Markdown
Contributor Author

@claude review

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread reflectapi/src/codegen/rust.rs Resolved
Comment thread reflectapi/src/codegen/rust.rs Resolved
Comment thread reflectapi/src/codegen/rust.rs Resolved
Comment thread reflectapi/src/codegen/rust.rs Resolved
…s stay exact

The transform now returns the fields it relaxed. The Rust client makes
exactly those Option<T> unless they already are, instead of guessing
whether their type implements Default: Option<T> always does, so error
types, generic and aliased types and primitives without Default all
compile, and deprecated fields with a server default keep their type.

Types decoded by trying them now include every variant of an enum with
an untagged fallback, and the type arguments wherever a reached generic
type is used, such as Either<Old, New> or a generic type flattening an
Option of its parameter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7zEtTUpmxtYWDkuq6uwpY
@antonzherdev

Copy link
Copy Markdown
Contributor Author

@claude review

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🔴 reflectapi/src/codegen/rust.rs — Rust clients see a deprecated Option<T> field of an error type, or a type reached from one, change type to reflectapi::Option<T>. Code that matches Some(..)/None on it stops compiling. The relaxed field now has required = false, so request_field_type_ref (rust.rs:1751) treats it as three-state. It does this because is_input_type is true for every type in extra_serializable_types. Fix: relaxed fields must keep std::option::Option. Pass schema.is_input_type(..) rather than the error-augmented flag into request_field_type_ref, or skip the rewrite when relaxed(..) is true. This applies to both the struct site and the enum-variant site (rust.rs:1888).

    Why this was flagged

    Take a function whose error_type is a response-only struct ApiError { #[deprecated] legacy_code: Option<String>, .. } with no serde attributes, so reflection marks it required = true. relax_deprecated_response_fields (deprecation.rs:35-47) sets required = false. In generate, rust.rs:200-201 passes schema.is_input_type(..) || extra_serializable_types.contains(..) as is_input_type. That flag is true for the error type and for every type it reaches. __render_type hands it to request_field_type_ref at rust.rs:1831 and rust.rs:1888. With required = false and an Option type, preserves_absence() (presence.rs:110-112) is true, so the type_ref is renamed to reflectapi::Option. The client field becomes pub legacy_code: reflectapi::Option<String> with skip_serializing_if = "reflectapi::Option::is_undefined". On the base branch the field stayed required and rendered as std::option::Option<String>.

    Verification: The trigger is a response-only error type, or a type reached from one, that has a deprecated Option<T> field with no skip_serializing_if. rust.rs:1751-1756 then renames the type to reflectapi::Option. On the base branch the field stayed required = true, preserves_absence() was false, and it rendered as std::option::Option<String>.

…ying

Substitute each use's type arguments into a generic type's fields, starting
from every response type and every function's output and error types, so a
generic untagged enum returned directly, or reached through a generic type
forwarding its parameter, keeps its arguments required. `Option` is no
longer treated as an untagged enum, so a type behind an `Option` field is
relaxed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7zEtTUpmxtYWDkuq6uwpY
@antonzherdev

Copy link
Copy Markdown
Contributor Author

@claude review

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-ran the review on the latest push and found no new bugs. A human should still look, because this deliberately changes generated TypeScript and Rust client types for deprecated response fields.

What was reviewed:

  • types_decoded_by_trying in schema/deprecation.rs. It now walks function output, error and stream types, and walks generic instances with their arguments substituted in. Its seeds include untagged variants of tagged enums and the type behind a flattened Option, and it does not seed from Option itself.
  • In rust.rs, optional_field_type uses the returned relaxed-field set, so only fields the pass relaxed become Option<T>. Other deprecated fields with #[serde(default)] keep their type.
Extended reasoning...

The change adds a schema pass (reflectapi/src/codegen/schema/deprecation.rs) that all four generators (OpenAPI, Python, TypeScript, Rust) run, and updates snapshots and field-presence tests. It touches no auth, crypto or data-exposure code. It is a user-visible change to generated client APIs: TypeScript adds ?: and Rust adds Option<T>. The CHANGELOG lists it under Changed. Across earlier pushes the decode-by-trying guard needed several rounds of fixes, so the remaining edge cases are worth a human's judgment, even though this run found nothing new.

@antonzherdev
antonzherdev merged commit 4325719 into main Nov 6, 2026
12 checks passed
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.

1 participant