diff --git a/CHANGELOG.md b/CHANGELOG.md index 59c5d9ff..870dd2a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixed + +- Generated clients keep a deprecated field required in the response copy of a type split by direction. When a type's request and response shapes differ, consolidation splits it into `input::X` and `output::X`, and 0.24.0-alpha.1 relaxed `output::X` as a response-only type. Code that sends back a value it read, such as an `output::X` passed where `input::X` is expected, then failed to type-check in TypeScript, because the field was optional in one and required in the other. Such a type is also used in requests, so it now keeps deprecated fields required, like any other type used in both directions. + ## 0.24.0-alpha.2 ### Fixed diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_deprecation_dates-2.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_deprecation_dates-2.snap index 8fa16f6b..ffe93326 100644 --- a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_deprecation_dates-2.snap +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_deprecation_dates-2.snap @@ -106,29 +106,29 @@ export namespace reflectapi_demo { /** * @deprecated Deprecated since 2026-10-01; removal after 2026-11-01. Use `current` instead. */ - _since_and_sunset?: number /* u8 */; + _since_and_sunset: number /* u8 */; /** * @deprecated Deprecated since 2026-10-01. */ - _since_only?: number /* u8 */; + _since_only: number /* u8 */; /** * @deprecated Removal after 2026-11-01. */ - _sunset_only?: number /* u8 */ | null; + _sunset_only: number /* u8 */ | null; /** * @deprecated Deprecated in 1.2.0. Versioned. * rustc's `since` is the release, kept verbatim. */ - _release_only?: number /* u8 */; + _release_only: number /* u8 */; /** * @deprecated Deprecated in 2026-10-01. * It is never read as a date, even when it looks like one. */ - _release_like_a_date?: number /* u8 */; + _release_like_a_date: number /* u8 */; /** * @deprecated Deprecated in 2.3.0, since 2026-10-01; removal after 2026-11-01. Use `current` instead. */ - _release_and_dates?: number /* u8 */; + _release_and_dates: number /* u8 */; _current: reflectapi_demo.tests.basic.EnumWithDeprecatedVariants; } } diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_deprecation_dates-3.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_deprecation_dates-3.snap index 00df7c22..a07b187a 100644 --- a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_deprecation_dates-3.snap +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_deprecation_dates-3.snap @@ -100,26 +100,20 @@ pub mod types { #[derive(Debug, serde::Deserialize)] pub struct StructWithDeprecationDates { - #[serde(default = "Default::default", skip_serializing_if = "std::option::Option::is_none")] #[deprecated(note = "Deprecated since 2026-10-01; removal after 2026-11-01. Use `current` instead.")] - pub _since_and_sunset: std::option::Option, - #[serde(default = "Default::default", skip_serializing_if = "std::option::Option::is_none")] + pub _since_and_sunset: u8, #[deprecated(note = "Deprecated since 2026-10-01.")] - pub _since_only: std::option::Option, - #[serde(default = "Default::default", skip_serializing_if = "std::option::Option::is_none")] + pub _since_only: u8, #[deprecated(note = "Removal after 2026-11-01.")] pub _sunset_only: std::option::Option, /// rustc's `since` is the release, kept verbatim. - #[serde(default = "Default::default", skip_serializing_if = "std::option::Option::is_none")] #[deprecated(note = "Deprecated in 1.2.0. Versioned.")] - pub _release_only: std::option::Option, + pub _release_only: u8, /// It is never read as a date, even when it looks like one. - #[serde(default = "Default::default", skip_serializing_if = "std::option::Option::is_none")] #[deprecated(note = "Deprecated in 2026-10-01.")] - pub _release_like_a_date: std::option::Option, - #[serde(default = "Default::default", skip_serializing_if = "std::option::Option::is_none")] + pub _release_like_a_date: u8, #[deprecated(note = "Deprecated in 2.3.0, since 2026-10-01; removal after 2026-11-01. Use `current` instead.")] - pub _release_and_dates: std::option::Option, + pub _release_and_dates: u8, pub _current: super::super::super::super::reflectapi_demo::tests::basic::EnumWithDeprecatedVariants, } } diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_deprecation_dates-4.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_deprecation_dates-4.snap index ff8d664a..a621d38f 100644 --- a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_deprecation_dates-4.snap +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_deprecation_dates-4.snap @@ -159,7 +159,13 @@ expression: "reflectapi :: codegen :: openapi :: Spec :: from(& schema)" "type": "object", "title": "reflectapi_demo.tests.basic.output.StructWithDeprecationDates", "required": [ - "_current" + "_current", + "_release_and_dates", + "_release_like_a_date", + "_release_only", + "_since_and_sunset", + "_since_only", + "_sunset_only" ], "properties": { "_current": { diff --git a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_deprecation_dates-5.snap b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_deprecation_dates-5.snap index 6cee51e1..75002897 100644 --- a/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_deprecation_dates-5.snap +++ b/reflectapi-demo/src/tests/snapshots/reflectapi_demo__tests__basic__reflectapi_deprecation_dates-5.snap @@ -22,7 +22,6 @@ from pydantic import ( ConfigDict, Field, RootModel, - field_validator, model_serializer, model_validator, ) @@ -187,49 +186,39 @@ class ReflectapiDemoTestsBasicInputStructWithDeprecationDates(ReflectapiPartialM ) -class ReflectapiDemoTestsBasicOutputStructWithDeprecationDates(ReflectapiPartialModel): +class ReflectapiDemoTestsBasicOutputStructWithDeprecationDates(BaseModel): model_config = ConfigDict( - extra="ignore", - populate_by_name=True, - validate_assignment=True, - protected_namespaces=(), - defer_build=True, + extra="ignore", populate_by_name=True, protected_namespaces=(), defer_build=True ) - since_and_sunset: int | None = Field( - default=None, + since_and_sunset: int = Field( serialization_alias="_since_and_sunset", validation_alias="_since_and_sunset", deprecated="Deprecated since 2026-10-01; removal after 2026-11-01. Use `current` instead.", ) - since_only: int | None = Field( - default=None, + since_only: int = Field( serialization_alias="_since_only", validation_alias="_since_only", deprecated="Deprecated since 2026-10-01.", ) sunset_only: int | None = Field( - default=None, serialization_alias="_sunset_only", validation_alias="_sunset_only", deprecated="Removal after 2026-11-01.", ) - release_only: int | None = Field( - default=None, + release_only: int = Field( serialization_alias="_release_only", validation_alias="_release_only", description="rustc's `since` is the release, kept verbatim.", deprecated="Deprecated in 1.2.0. Versioned.", ) - release_like_a_date: int | None = Field( - default=None, + release_like_a_date: int = Field( serialization_alias="_release_like_a_date", validation_alias="_release_like_a_date", description="It is never read as a date, even when it looks like one.", deprecated="Deprecated in 2026-10-01.", ) - release_and_dates: int | None = Field( - default=None, + release_and_dates: int = Field( serialization_alias="_release_and_dates", validation_alias="_release_and_dates", deprecated="Deprecated in 2.3.0, since 2026-10-01; removal after 2026-11-01. Use `current` instead.", @@ -238,20 +227,6 @@ class ReflectapiDemoTestsBasicOutputStructWithDeprecationDates(ReflectapiPartial serialization_alias="_current", validation_alias="_current" ) - @field_validator( - "since_and_sunset", - "since_only", - "release_only", - "release_like_a_date", - "release_and_dates", - mode="before", - ) - @classmethod - def _reflectapi_reject_null(cls, value): - if value is None: - raise ValueError("Field may be omitted but cannot be null") - return value - # Namespace classes for dotted access to types class reflectapi_demo: diff --git a/reflectapi/src/codegen/schema/deprecation.rs b/reflectapi/src/codegen/schema/deprecation.rs index 103fcd25..f597384c 100644 --- a/reflectapi/src/codegen/schema/deprecation.rs +++ b/reflectapi/src/codegen/schema/deprecation.rs @@ -31,7 +31,11 @@ pub(crate) fn relax_deprecated_response_fields(schema: &mut Schema) -> HashSet, field: &mut Field| { @@ -67,6 +71,22 @@ pub(crate) fn relax_deprecated_response_fields(schema: &mut Schema) -> HashSet bool { + let mut parts: Vec<&str> = name.split("::").collect(); + match parts.len().checked_sub(2) { + Some(module) if parts[module] == "output" => { + parts[module] = "input"; + schema.input_types.has_type(&parts.join("::")) + } + _ => false, + } +} + /// Types a client decodes by trying them in turn, and every type these /// reach. A first-match decoder takes the first variant that matches, and a /// flattened `Option` is `None` only when its type fails to decode, so an @@ -351,4 +371,29 @@ mod tests { [("old".into(), true)] ); } + + #[test] + fn the_response_copy_of_a_split_type_keeps_deprecated_fields() { + let old = serde_json::json!({"name": "old", "required": true, "type": {"name": "u32"}, "deprecation_note": "gone soon"}); + let mut schema: Schema = serde_json::from_value(serde_json::json!({ + "name": "split", + "input_types": {"types": [ + {"kind": "primitive", "name": "u32"}, + {"kind": "struct", "name": "rules::input::Split", "fields": {"named": [old.clone()]}} + ]}, + "output_types": {"types": [ + {"kind": "primitive", "name": "u32"}, + {"kind": "struct", "name": "rules::output::Split", "fields": {"named": [old.clone()]}}, + {"kind": "struct", "name": "replies::output::Lone", "fields": {"named": [old]}} + ]} + })) + .unwrap(); + let relaxed = super::relax_deprecated_response_fields(&mut schema); + // A client may send back the `output` copy it read as the `input` + // twin; an `output` module without a twin is an ordinary response. + assert_eq!( + relaxed.into_iter().map(|f| f.type_name).collect::>(), + ["replies::output::Lone"] + ); + } }