From 905fa7961c58c1c95b53e3825a5c9c40fefb7671 Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:03:38 +0000 Subject: [PATCH] fix(rust): preserve inherited fields in union variants --- .../model/src/__test__/generateModels.test.ts | 17 +++++++ .../union-types/types_plant_event.rs | 40 ++++++++++++++++ .../union-types/types_plant_event_base.rs | 35 ++++++++++++++ .../union-types/types_sprouted_event.rs | 35 ++++++++++++++ .../union-types/types_watered_event.rs | 46 ++++++++++++++++++ .../union-types/definition/types.yml | 25 ++++++++++ generators/rust/model/src/generateModels.ts | 6 ++- ...fix-union-variant-inherited-properties.yml | 8 ++++ .../src/api/types/mod.rs | 2 + .../src/api/types/types_foo_extended.rs | 47 +++++++++++++++++++ .../api/types/types_union_with_sub_types.rs | 8 ++-- seed/rust-sdk/unions/src/api/types/mod.rs | 2 + .../src/api/types/types_foo_extended.rs | 47 +++++++++++++++++++ .../api/types/types_union_with_sub_types.rs | 8 ++-- 14 files changed, 317 insertions(+), 9 deletions(-) create mode 100644 generators/rust/model/src/__test__/snapshots/union-types/types_plant_event.rs create mode 100644 generators/rust/model/src/__test__/snapshots/union-types/types_plant_event_base.rs create mode 100644 generators/rust/model/src/__test__/snapshots/union-types/types_sprouted_event.rs create mode 100644 generators/rust/model/src/__test__/snapshots/union-types/types_watered_event.rs create mode 100644 generators/rust/sdk/changes/unreleased/fix-union-variant-inherited-properties.yml create mode 100644 seed/rust-sdk/unions-with-local-date/src/api/types/types_foo_extended.rs create mode 100644 seed/rust-sdk/unions/src/api/types/types_foo_extended.rs diff --git a/generators/rust/model/src/__test__/generateModels.test.ts b/generators/rust/model/src/__test__/generateModels.test.ts index 04a9faac9cd2..87ff2e0df7e6 100644 --- a/generators/rust/model/src/__test__/generateModels.test.ts +++ b/generators/rust/model/src/__test__/generateModels.test.ts @@ -107,6 +107,23 @@ describe("generateModels type-specific tests", () => { expect(hasTaggedUnion).toBeTruthy(); }); + it("should keep the flattened wrapper for union variants that inherit properties", async () => { + const context = await createSampleGeneratorContext("union-types"); + const files = generateModels({ context }); + + // SproutedEvent has no properties of its own and WateredEvent has one, but both + // inherit `occurred_at` from PlantEventBase. Inlining copies own properties only, + // so these variants must keep the `#[serde(flatten)]` wrapper or the inherited + // fields would be silently dropped from the wire payload. + const plantEvent = files.find((file) => file.fileContents.includes("pub enum PlantEvent")); + expect(plantEvent?.fileContents).toContain("data: SproutedEvent,"); + expect(plantEvent?.fileContents).toContain("data: WateredEvent,"); + + // The wrapper structs must still be generated, with their inherited fields. + const sproutedEvent = files.find((file) => file.fileContents.includes("pub struct SproutedEvent")); + expect(sproutedEvent?.fileContents).toContain("plant_event_base_fields: PlantEventBase"); + }); + it("should generate unions for union types (all are discriminated in Fern)", async () => { const context = await createSampleGeneratorContext("undiscriminated-union-types"); const files = generateModels({ context }); diff --git a/generators/rust/model/src/__test__/snapshots/union-types/types_plant_event.rs b/generators/rust/model/src/__test__/snapshots/union-types/types_plant_event.rs new file mode 100644 index 000000000000..d137680dbe99 --- /dev/null +++ b/generators/rust/model/src/__test__/snapshots/union-types/types_plant_event.rs @@ -0,0 +1,40 @@ +pub use crate::prelude::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "event")] +#[non_exhaustive] +pub enum PlantEvent { + #[serde(rename = "sprouted")] + #[non_exhaustive] + Sprouted { + #[serde(flatten)] + data: SproutedEvent, + }, + + #[serde(rename = "watered")] + #[non_exhaustive] + Watered { + #[serde(flatten)] + data: WateredEvent, + }, + + /// Catch-all variant for unrecognized discriminant values. + /// If the server sends a discriminant not recognized by the current SDK + /// version, the raw payload is captured here so callers can still inspect it. + #[serde(untagged)] + __Unknown(serde_json::Value), +} + +impl PlantEvent { + pub fn sprouted(data: SproutedEvent) -> Self { + Self::Sprouted { data } + } + + pub fn watered(data: WateredEvent) -> Self { + Self::Watered { data } + } + + pub fn unknown(value: serde_json::Value) -> Self { + Self::__Unknown(value) + } +} diff --git a/generators/rust/model/src/__test__/snapshots/union-types/types_plant_event_base.rs b/generators/rust/model/src/__test__/snapshots/union-types/types_plant_event_base.rs new file mode 100644 index 000000000000..55ed24cb35f1 --- /dev/null +++ b/generators/rust/model/src/__test__/snapshots/union-types/types_plant_event_base.rs @@ -0,0 +1,35 @@ +pub use crate::prelude::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct PlantEventBase { + #[serde(default)] + pub occurred_at: String, +} + +impl PlantEventBase { + pub fn builder() -> PlantEventBaseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PlantEventBaseBuilder { + occurred_at: Option, +} + +impl PlantEventBaseBuilder { + pub fn occurred_at(mut self, value: impl Into) -> Self { + self.occurred_at = Some(value.into()); + self + } + + /// Consumes the builder and constructs a [`PlantEventBase`]. + /// This method will fail if any of the following fields are not set: + /// - [`occurred_at`](PlantEventBaseBuilder::occurred_at) + pub fn build(self) -> Result { + Ok(PlantEventBase { + occurred_at: self.occurred_at.ok_or_else(|| BuildError::missing_field("occurred_at"))?, + }) + } +} diff --git a/generators/rust/model/src/__test__/snapshots/union-types/types_sprouted_event.rs b/generators/rust/model/src/__test__/snapshots/union-types/types_sprouted_event.rs new file mode 100644 index 000000000000..ae986cd485bb --- /dev/null +++ b/generators/rust/model/src/__test__/snapshots/union-types/types_sprouted_event.rs @@ -0,0 +1,35 @@ +pub use crate::prelude::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct SproutedEvent { + #[serde(flatten)] + pub plant_event_base_fields: PlantEventBase, +} + +impl SproutedEvent { + pub fn builder() -> SproutedEventBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct SproutedEventBuilder { + plant_event_base_fields: Option, +} + +impl SproutedEventBuilder { + pub fn plant_event_base_fields(mut self, value: PlantEventBase) -> Self { + self.plant_event_base_fields = Some(value); + self + } + + /// Consumes the builder and constructs a [`SproutedEvent`]. + /// This method will fail if any of the following fields are not set: + /// - [`plant_event_base_fields`](SproutedEventBuilder::plant_event_base_fields) + pub fn build(self) -> Result { + Ok(SproutedEvent { + plant_event_base_fields: self.plant_event_base_fields.ok_or_else(|| BuildError::missing_field("plant_event_base_fields"))?, + }) + } +} diff --git a/generators/rust/model/src/__test__/snapshots/union-types/types_watered_event.rs b/generators/rust/model/src/__test__/snapshots/union-types/types_watered_event.rs new file mode 100644 index 000000000000..baab2da8eb37 --- /dev/null +++ b/generators/rust/model/src/__test__/snapshots/union-types/types_watered_event.rs @@ -0,0 +1,46 @@ +pub use crate::prelude::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct WateredEvent { + #[serde(flatten)] + pub plant_event_base_fields: PlantEventBase, + #[serde(default)] + #[serde(with = "crate::core::number_serializers")] + pub liters: f64, +} + +impl WateredEvent { + pub fn builder() -> WateredEventBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct WateredEventBuilder { + plant_event_base_fields: Option, + liters: Option, +} + +impl WateredEventBuilder { + pub fn plant_event_base_fields(mut self, value: PlantEventBase) -> Self { + self.plant_event_base_fields = Some(value); + self + } + + pub fn liters(mut self, value: f64) -> Self { + self.liters = Some(value); + self + } + + /// Consumes the builder and constructs a [`WateredEvent`]. + /// This method will fail if any of the following fields are not set: + /// - [`plant_event_base_fields`](WateredEventBuilder::plant_event_base_fields) + /// - [`liters`](WateredEventBuilder::liters) + pub fn build(self) -> Result { + Ok(WateredEvent { + plant_event_base_fields: self.plant_event_base_fields.ok_or_else(|| BuildError::missing_field("plant_event_base_fields"))?, + liters: self.liters.ok_or_else(|| BuildError::missing_field("liters"))?, + }) + } +} diff --git a/generators/rust/model/src/__test__/test-definitions/union-types/definition/types.yml b/generators/rust/model/src/__test__/test-definitions/union-types/definition/types.yml index 5a078d053b45..b46020f6d190 100644 --- a/generators/rust/model/src/__test__/test-definitions/union-types/definition/types.yml +++ b/generators/rust/model/src/__test__/test-definitions/union-types/definition/types.yml @@ -100,3 +100,28 @@ types: - CSV - XML - YAML + + # Union whose variants inherit their fields via extends. The variant types are each + # referenced once, so they are inlining candidates, but inlining would drop everything + # they inherit. + PlantEvent: + discriminant: event + union: + sprouted: + type: SproutedEvent + watered: + type: WateredEvent + + PlantEventBase: + properties: + occurred_at: string + + SproutedEvent: + extends: + - PlantEventBase + + WateredEvent: + extends: + - PlantEventBase + properties: + liters: double diff --git a/generators/rust/model/src/generateModels.ts b/generators/rust/model/src/generateModels.ts index 28a946c08266..ab5b042bfa1a 100644 --- a/generators/rust/model/src/generateModels.ts +++ b/generators/rust/model/src/generateModels.ts @@ -151,6 +151,9 @@ export function generateModels({ context }: { context: ModelGeneratorContext }): * 2. It is not referenced anywhere else in the IR (object fields, other unions, * service endpoints, aliases, containers, etc.) * 3. It is an object type (not an enum, alias, or another union) + * 4. It does not inherit properties via `extends`. Inlining copies the object's own + * properties only, so inheriting types would lose every inherited field; they keep + * the `#[serde(flatten)]` wrapper struct, which preserves the full shape. */ function computeInlinedUnionVariantTypeIds(context: ModelGeneratorContext): void { const ir = context.ir; @@ -287,11 +290,12 @@ function computeInlinedUnionVariantTypeIds(context: ModelGeneratorContext): void // - Referenced exactly once (the samePropertiesAsObject reference) // - That one reference is as samePropertiesAsObject // - The type is an object (not enum, alias, or union) + // - The object declares no `extends` (inherited properties are not inlined) for (const typeId of samePropertiesRefs) { const count = referenceCount.get(typeId) ?? 0; if (count === 1) { const typeDecl = ir.types[typeId]; - if (typeDecl?.shape.type === "object") { + if (typeDecl?.shape.type === "object" && typeDecl.shape.extends.length === 0) { context.inlinedUnionVariantTypeIds.add(typeId); } } diff --git a/generators/rust/sdk/changes/unreleased/fix-union-variant-inherited-properties.yml b/generators/rust/sdk/changes/unreleased/fix-union-variant-inherited-properties.yml new file mode 100644 index 000000000000..434b3facf69d --- /dev/null +++ b/generators/rust/sdk/changes/unreleased/fix-union-variant-inherited-properties.yml @@ -0,0 +1,8 @@ +# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json + +- summary: | + Fix discriminated union variants silently dropping inherited properties. When a variant's + referenced object type got all of its properties from `extends`, the variant was inlined to + an empty struct, so the payload deserialized successfully but every field was discarded. + Such types are no longer inlined and keep their `#[serde(flatten)]` wrapper. + type: fix diff --git a/seed/rust-sdk/unions-with-local-date/src/api/types/mod.rs b/seed/rust-sdk/unions-with-local-date/src/api/types/mod.rs index 15abac6c18c8..5e719ce3bbcf 100644 --- a/seed/rust-sdk/unions-with-local-date/src/api/types/mod.rs +++ b/seed/rust-sdk/unions-with-local-date/src/api/types/mod.rs @@ -1,6 +1,7 @@ pub mod bigunion_big_union; pub mod types_bar; pub mod types_foo; +pub mod types_foo_extended; pub mod types_union; pub mod types_union_with_base_properties; pub mod types_union_with_discriminant; @@ -24,6 +25,7 @@ pub mod union_with_name; pub use bigunion_big_union::BigUnion; pub use types_bar::Bar; pub use types_foo::Foo; +pub use types_foo_extended::FooExtended; pub use types_union::Union; pub use types_union_with_base_properties::UnionWithBaseProperties; pub use types_union_with_discriminant::UnionWithDiscriminant; diff --git a/seed/rust-sdk/unions-with-local-date/src/api/types/types_foo_extended.rs b/seed/rust-sdk/unions-with-local-date/src/api/types/types_foo_extended.rs new file mode 100644 index 000000000000..b225d044752b --- /dev/null +++ b/seed/rust-sdk/unions-with-local-date/src/api/types/types_foo_extended.rs @@ -0,0 +1,47 @@ +pub use crate::prelude::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct FooExtended { + #[serde(flatten)] + pub foo_fields: Foo, + #[serde(default)] + pub age: i64, +} + +impl FooExtended { + pub fn builder() -> FooExtendedBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct FooExtendedBuilder { + foo_fields: Option, + age: Option, +} + +impl FooExtendedBuilder { + pub fn foo_fields(mut self, value: Foo) -> Self { + self.foo_fields = Some(value); + self + } + + pub fn age(mut self, value: i64) -> Self { + self.age = Some(value); + self + } + + /// Consumes the builder and constructs a [`FooExtended`]. + /// This method will fail if any of the following fields are not set: + /// - [`foo_fields`](FooExtendedBuilder::foo_fields) + /// - [`age`](FooExtendedBuilder::age) + pub fn build(self) -> Result { + Ok(FooExtended { + foo_fields: self + .foo_fields + .ok_or_else(|| BuildError::missing_field("foo_fields"))?, + age: self.age.ok_or_else(|| BuildError::missing_field("age"))?, + }) + } +} diff --git a/seed/rust-sdk/unions-with-local-date/src/api/types/types_union_with_sub_types.rs b/seed/rust-sdk/unions-with-local-date/src/api/types/types_union_with_sub_types.rs index 7bd821568a5a..696bc6e6a006 100644 --- a/seed/rust-sdk/unions-with-local-date/src/api/types/types_union_with_sub_types.rs +++ b/seed/rust-sdk/unions-with-local-date/src/api/types/types_union_with_sub_types.rs @@ -14,8 +14,8 @@ pub enum UnionWithSubTypes { #[serde(rename = "fooExtended")] #[non_exhaustive] FooExtended { - #[serde(default)] - age: i64, + #[serde(flatten)] + data: FooExtended, }, /// Catch-all variant for unrecognized discriminant values. @@ -30,8 +30,8 @@ impl UnionWithSubTypes { Self::Foo { data } } - pub fn foo_extended(age: i64) -> Self { - Self::FooExtended { age } + pub fn foo_extended(data: FooExtended) -> Self { + Self::FooExtended { data } } pub fn unknown(value: serde_json::Value) -> Self { diff --git a/seed/rust-sdk/unions/src/api/types/mod.rs b/seed/rust-sdk/unions/src/api/types/mod.rs index 866abb132302..70f18685599b 100644 --- a/seed/rust-sdk/unions/src/api/types/mod.rs +++ b/seed/rust-sdk/unions/src/api/types/mod.rs @@ -1,6 +1,7 @@ pub mod bigunion_big_union; pub mod types_bar; pub mod types_foo; +pub mod types_foo_extended; pub mod types_type_with_optional_map; pub mod types_type_with_optional_reference_map; pub mod types_union; @@ -29,6 +30,7 @@ pub mod union_with_name; pub use bigunion_big_union::BigUnion; pub use types_bar::Bar; pub use types_foo::Foo; +pub use types_foo_extended::FooExtended; pub use types_type_with_optional_map::TypeWithOptionalMap; pub use types_type_with_optional_reference_map::TypeWithOptionalReferenceMap; pub use types_union::Union; diff --git a/seed/rust-sdk/unions/src/api/types/types_foo_extended.rs b/seed/rust-sdk/unions/src/api/types/types_foo_extended.rs new file mode 100644 index 000000000000..b225d044752b --- /dev/null +++ b/seed/rust-sdk/unions/src/api/types/types_foo_extended.rs @@ -0,0 +1,47 @@ +pub use crate::prelude::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct FooExtended { + #[serde(flatten)] + pub foo_fields: Foo, + #[serde(default)] + pub age: i64, +} + +impl FooExtended { + pub fn builder() -> FooExtendedBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct FooExtendedBuilder { + foo_fields: Option, + age: Option, +} + +impl FooExtendedBuilder { + pub fn foo_fields(mut self, value: Foo) -> Self { + self.foo_fields = Some(value); + self + } + + pub fn age(mut self, value: i64) -> Self { + self.age = Some(value); + self + } + + /// Consumes the builder and constructs a [`FooExtended`]. + /// This method will fail if any of the following fields are not set: + /// - [`foo_fields`](FooExtendedBuilder::foo_fields) + /// - [`age`](FooExtendedBuilder::age) + pub fn build(self) -> Result { + Ok(FooExtended { + foo_fields: self + .foo_fields + .ok_or_else(|| BuildError::missing_field("foo_fields"))?, + age: self.age.ok_or_else(|| BuildError::missing_field("age"))?, + }) + } +} diff --git a/seed/rust-sdk/unions/src/api/types/types_union_with_sub_types.rs b/seed/rust-sdk/unions/src/api/types/types_union_with_sub_types.rs index 7bd821568a5a..696bc6e6a006 100644 --- a/seed/rust-sdk/unions/src/api/types/types_union_with_sub_types.rs +++ b/seed/rust-sdk/unions/src/api/types/types_union_with_sub_types.rs @@ -14,8 +14,8 @@ pub enum UnionWithSubTypes { #[serde(rename = "fooExtended")] #[non_exhaustive] FooExtended { - #[serde(default)] - age: i64, + #[serde(flatten)] + data: FooExtended, }, /// Catch-all variant for unrecognized discriminant values. @@ -30,8 +30,8 @@ impl UnionWithSubTypes { Self::Foo { data } } - pub fn foo_extended(age: i64) -> Self { - Self::FooExtended { age } + pub fn foo_extended(data: FooExtended) -> Self { + Self::FooExtended { data } } pub fn unknown(value: serde_json::Value) -> Self {