Repository navigation
fix(codegen): responses may omit deprecated fields - #218
Conversation
|
📖 Documentation Preview: https://reflectapi-docs-preview-pr-218.partly.workers.dev Updated automatically from commit d2a834d |
There was a problem hiding this comment.
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.
|
@claude review |
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
9426d45 to
3c89ab7
Compare
…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
|
@claude review |
There was a problem hiding this comment.
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 withoutDefault, such asurl::Url,std::net::IpAddrorstd::num::NonZeroU32.type_implements_defaultreturnstruefor 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 implementDefault, and wrap every other relaxed field inOption<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) setsrequired = false.optional_field_type(rust.rs:1717-1723) keeps the typeurl::Urlbecausetype_implements_defaultfindsurl::Urlas aType::Primitivein the typespace and falls to_ => trueat rust.rs:1703.render_attributes(rust.rs:1061-1062) then emits#[serde(default = "Default::default")]on aurl::Urlfield.Urlhas noDefault, 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 neededDefault. The new pass makes it reachable for any deprecated field type: url::Url, IpAddr/Ipv4Addr, NonZero*, arrays longer than 32, and genericT, where serde adds aT: Defaultbound.Verification: The pass at deprecation.rs:23-31 sets
field.required = falseon deprecated required fields without checking their type. Forurl::Url,type_implements_defaultreaches_ => trueat rust.rs:1703, sodefault = "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 requiredDefault. -
🔴
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_defaultassumes generic parameters implementDefault. The field template emitsdefault = "Default::default"(rust.rs:1062), and serde adds noT: Defaultbound for a path default. So the generatedDeserializeimpl hits E0277. Fix: treat a bare type parameter (also behind Box, Rc or Arc) as having noDefault, so the field becomesOption<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) setsresult.required = false. optional_field_type (rust.rs:1717-1719) keeps the type asT, 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 aT: Defaultbound only for a baredefault, not fordefault = "path". The struct template (rust.rs:453-460) adds no where clause, so theimpl<'de, T: Deserialize<'de>> Deserialize<'de> for Envelope<T>callsDefault::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
|
Both Rust |
|
@claude review |
…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
|
@claude review |
There was a problem hiding this comment.
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 deprecatedOption<T>field of an error type, or a type reached from one, change type toreflectapi::Option<T>. Code that matchesSome(..)/Noneon it stops compiling. The relaxed field now hasrequired = false, sorequest_field_type_ref(rust.rs:1751) treats it as three-state. It does this becauseis_input_typeis true for every type inextra_serializable_types. Fix: relaxed fields must keepstd::option::Option. Passschema.is_input_type(..)rather than the error-augmented flag intorequest_field_type_ref, or skip the rewrite whenrelaxed(..)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_typeis a response-only structApiError { #[deprecated] legacy_code: Option<String>, .. }with no serde attributes, so reflection marks itrequired = true.relax_deprecated_response_fields(deprecation.rs:35-47) setsrequired = false. Ingenerate, rust.rs:200-201 passesschema.is_input_type(..) || extra_serializable_types.contains(..)asis_input_type. That flag is true for the error type and for every type it reaches.__render_typehands it torequest_field_type_refat rust.rs:1831 and rust.rs:1888. Withrequired = falseand anOptiontype,preserves_absence()(presence.rs:110-112) is true, so the type_ref is renamed toreflectapi::Option. The client field becomespub legacy_code: reflectapi::Option<String>withskip_serializing_if = "reflectapi::Option::is_undefined". On the base branch the field stayed required and rendered asstd::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 noskip_serializing_if. rust.rs:1751-1756 then renames the type toreflectapi::Option. On the base branch the field stayedrequired = true,preserves_absence()was false, and it rendered asstd::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
|
@claude review |
There was a problem hiding this comment.
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_tryinginschema/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 flattenedOption, and it does not seed fromOptionitself.- In
rust.rs,optional_field_typeuses the returned relaxed-field set, so only fields the pass relaxed becomeOption<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.
What changes
A schema transform in
codegen/schemamakes 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:
legacy_code: Stringbeforestr = Field(deprecated=...): requiredstr | None = Field(default=None, deprecated=...)legacy_code: stringlegacy_code?: stringpub legacy_code: String#[serde(default)] pub legacy_code: Option<String>. A field already typedOption<T>keeps its type.Option<T>implementsDefaultfor anyT, so this compiles for type parameters and for types withoutDefaultrequiredA response that lacks the field used to fail as a whole (
legacy_code Field required). It now decodes, and the field reads asNoneorundefined.A type that the Builder splits into
input::andoutput::copies counts as response-only on itsoutput::side, so its requests still require the field.What stays the same
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 flattenedOptioncould decode asSomewith defaults where the server meantNone.Changed, not just fixed
TypeScript and Rust code that reads such a field now has to handle its absence:
?:in TypeScript, andOption<T>in Rust. Only deprecated fields are affected. The changelog lists this under Changed.Testing
Profiledecodes without its deprecatedOption,String, struct andNonZeroU32fields, and a genericEnvelope<T>without its deprecatedresult: T. It's checked in Python and in the Rust client, the TypeScript type accepts{ id: 1 }, and the OpenAPI definition requires onlyid. A deprecated field of the sharedSharedtype stays required in each of them.deprecated_fields_of_response_only_types_may_be_absentincodegen/schema/deprecation.rs.reflectapi_deprecation_datesandreflectapi_deprecated_headers, the output copies gain?:(TypeScript),#[serde(default)](Rust) anddefault=None(Python), and leave OpenAPI'srequired. That includes a deprecated response header.🤖 Generated with Claude Code
https://claude.ai/code/session_01Q7zEtTUpmxtYWDkuq6uwpY