Repository navigation
Conversation
A schema with a discriminator and mapped children generated a serde internally-tagged enum whose variants were inline structs built from the parent's vars - every child-specific field was silently dropped, and with duplicate mappings the variants even mixed vars across models. Wrap the mapped model in a newtype variant instead (boxed, like the oneOf variants), named by the uniquified modelName while wrapping the model's real classname. serde's internally-tagged deserialization consumes the tag key, so a wrapped child's own required discriminator property would fail with "missing field": RustClientCodegen now marks mapped children's discriminator properties (the rust var context never set isDiscriminator) and the template defaults them, skipping them back out while empty so the tag stays the only occurrence on the wire. Optional discriminator properties already tolerate absence through Option and are untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GcwZ1arjLZNpetHz2a3TJz
There was a problem hiding this comment.
All reported issues were addressed across 26 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
Review found the serde-attribute approach broke on a required nullable discriminator (String::is_empty on an Option<String> does not compile) and could duplicate the tag when a caller populated the child's field. Remove the property from mapped children instead, exactly as postProcessModels already removes it from the discriminating parent: the variant name carries the type information, deserialization never misses a consumed tag key, and the tag is structurally the only occurrence on the wire. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GcwZ1arjLZNpetHz2a3TJz
|
cubic's findings reshaped this for the better. The serde-attribute approach is gone: mapped children now have their discriminator property removed, exactly as
Verified again end to end: the serde round-trip on the generated crate still passes (child fields populated, exactly one |
There was a problem hiding this comment.
All reported issues were addressed across 51 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…de tag Removing the property from mapped children went too far: getMappedModels() covers every allOf descendant, and those models are also returned and accepted standalone (create_bar returns Bar, create_foo takes Foo), so they lost a required field and their new() signature changed. Keep the property declared and mark it instead, so the template defaults it - the internally-tagged union consumes the key before the child deserializes - and skips it back out while unset, keeping the tag the only occurrence on the wire. The skip predicate follows the type: Option::is_none for a nullable discriminator, String::is_empty for a non-nullable string, which is what the first attempt got wrong. Standalone use is unchanged: the caller sets and reads the discriminator as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GcwZ1arjLZNpetHz2a3TJz
There was a problem hiding this comment.
All reported issues were addressed across 52 files (changes from recent commits).
Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
|
cubic is right on both mechanisms, and measuring them makes the picture worse than the review states. Answering here rather than in three sample files, since all four violations are one design question. Reproduced against the exact shape the Three things follow:
The round-trips I reported on the previous push missed all three because they constructed children with the discriminator left unset, which is the one shape that behaves. My mistake, and worth saying plainly. On the suggested remedy. "Remove the discriminator property from mapped child models" is the rework this PR already tried and withdrew, on cubic's own reading in #24897 (comment): Two that work: A. Variant structs, populated from the child. Go back to an inline struct per variant, but build it from the child's own vars minus the discriminator instead of from the parent's vars. That fixes the original defect this PR opened for — child-specific fields silently dropped — and every point above, and leaves standalone models untouched. Cost: a variant is not the model type, so a B. Keep the newtype variants and generate I lean to B because the newtype variants are the point of the PR, but A is the smaller change and I am happy to write either — a preference from a rust maintainer would settle it faster than me guessing. Either way the fixture gains a round-trip that builds a variant through |
serde's internally tagged enum consumes the tag before the wrapped child deserializes and writes it again next to the child's own discriminator field. The previous attempt defaulted and skipped that field on the child, which broke enum-typed discriminators (E0308 on `skip_serializing_if = "String::is_empty"`) and still wrote the tag twice for a child built with `new()`. The newtype unions now get a generated Serialize/Deserialize that goes through serde_json::Value: the child reads its own discriminator from the payload, and the variant's tag is written exactly once. Children are back to master (no attribute changes, no Java marking). A union whose mapping names the base itself has no struct to wrap and keeps master's inline variants. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
2 issues found across 26 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RustClientCodegen.java">
<violation number="1" location="modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RustClientCodegen.java:347">
P2: The all-or-nothing `x-rust-newtype-variants` flag falls back to inline struct variants for the entire union as soon as *any* mapping names the base itself, so non-self mapped children in the same union are silently stripped of their child-specific fields — the exact bug this PR fixes. In the new fixture's `Creature` union (`mapping: {Creature: ...Creature, Bird: ...Bird}`), `selfMapped` is true, so `Bird` (allOf Creature + `wingspan`) is generated as an inline variant built from the parent's `vars` (`{{#vars}}` resolves to the union model's own `vars`, which after removing `kind` only contains `name`), dropping `wingspan`. Deserializing `{"kind":"Bird","wingspan":5}` then silently discards `wingspan` (serde ignores unknown fields), and the test added for this case only asserts `Self::Bird {` exists, never the field. Generate newtype variants for every mapping entry that is not the base, and inline only the self-mapped entry (variants can mix tuple and struct forms in one enum).</violation>
</file>
<file name="modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustClientCodegenTest.java">
<violation number="1" location="modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustClientCodegenTest.java:314">
P3: The comment claims the tag is written once, but nothing in this test executes the serde path: `testDiscriminatedUnionKeepsChildFields` contains only `assertFileContains`/`assertFileNotContains` source-text checks and never serializes or deserializes a value. The previous design's failure mode was a runtime duplicate-tag bug (a `new()`-constructed boxed variant serialized to two `@type` keys and failed to read back), so the source assertions can all pass while the wire format is broken.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| // struct to wrap, so that union keeps the inline variants built from the base's own vars. | ||
| boolean selfMapped = cm.discriminator.getMappedModels() != null && cm.discriminator.getMappedModels().stream() | ||
| .anyMatch(m -> cm.name.equals(m.getSchemaName()) || cm.classname.equals(m.getModelName())); | ||
| cm.discriminator.getVendorExtensions().put("x-rust-newtype-variants", cm.oneOf.isEmpty() && !selfMapped); |
There was a problem hiding this comment.
P2: The all-or-nothing x-rust-newtype-variants flag falls back to inline struct variants for the entire union as soon as any mapping names the base itself, so non-self mapped children in the same union are silently stripped of their child-specific fields — the exact bug this PR fixes. In the new fixture's Creature union (mapping: {Creature: ...Creature, Bird: ...Bird}), selfMapped is true, so Bird (allOf Creature + wingspan) is generated as an inline variant built from the parent's vars ({{#vars}} resolves to the union model's own vars, which after removing kind only contains name), dropping wingspan. Deserializing {"kind":"Bird","wingspan":5} then silently discards wingspan (serde ignores unknown fields), and the test added for this case only asserts Self::Bird { exists, never the field. Generate newtype variants for every mapping entry that is not the base, and inline only the self-mapped entry (variants can mix tuple and struct forms in one enum).
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RustClientCodegen.java, line 347:
<comment>The all-or-nothing `x-rust-newtype-variants` flag falls back to inline struct variants for the entire union as soon as *any* mapping names the base itself, so non-self mapped children in the same union are silently stripped of their child-specific fields — the exact bug this PR fixes. In the new fixture's `Creature` union (`mapping: {Creature: ...Creature, Bird: ...Bird}`), `selfMapped` is true, so `Bird` (allOf Creature + `wingspan`) is generated as an inline variant built from the parent's `vars` (`{{#vars}}` resolves to the union model's own `vars`, which after removing `kind` only contains `name`), dropping `wingspan`. Deserializing `{"kind":"Bird","wingspan":5}` then silently discards `wingspan` (serde ignores unknown fields), and the test added for this case only asserts `Self::Bird {` exists, never the field. Generate newtype variants for every mapping entry that is not the base, and inline only the self-mapped entry (variants can mix tuple and struct forms in one enum).</comment>
<file context>
@@ -384,6 +340,12 @@ public ModelsMap postProcessModels(ModelsMap objs) {
+ // struct to wrap, so that union keeps the inline variants built from the base's own vars.
+ boolean selfMapped = cm.discriminator.getMappedModels() != null && cm.discriminator.getMappedModels().stream()
+ .anyMatch(m -> cm.name.equals(m.getSchemaName()) || cm.classname.equals(m.getModelName()));
+ cm.discriminator.getVendorExtensions().put("x-rust-newtype-variants", cm.oneOf.isEmpty() && !selfMapped);
+
for (CodegenProperty cp : cm.vars) {
</file context>
There was a problem hiding this comment.
The hand-written serde was withdrawn in 1c0da30; unions derive #[serde(tag)] again. Still applies, deliberately: a self-mapped union keeps master's inline variants whole (e9ad7d4), so its other children still lose their fields, as on master. Wrapping the base in its own union is E0275, and mixing variant shapes per entry is left out of this PR. It is listed under Known gaps in the description.
| TestUtils.assertFileContains(unionPath, "Self::ObjectExists(Default::default())"); | ||
| TestUtils.assertFileNotContains(unionPath, "ObjectExists {"); | ||
|
|
||
| // not serde's internally tagged enum: the child reads its own discriminator, the tag is written once |
There was a problem hiding this comment.
P3: The comment claims the tag is written once, but nothing in this test executes the serde path: testDiscriminatedUnionKeepsChildFields contains only assertFileContains/assertFileNotContains source-text checks and never serializes or deserializes a value. The previous design's failure mode was a runtime duplicate-tag bug (a new()-constructed boxed variant serialized to two @type keys and failed to read back), so the source assertions can all pass while the wire format is broken.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustClientCodegenTest.java, line 314:
<comment>The comment claims the tag is written once, but nothing in this test executes the serde path: `testDiscriminatedUnionKeepsChildFields` contains only `assertFileContains`/`assertFileNotContains` source-text checks and never serializes or deserializes a value. The previous design's failure mode was a runtime duplicate-tag bug (a `new()`-constructed boxed variant serialized to two `@type` keys and failed to read back), so the source assertions can all pass while the wire format is broken.</comment>
<file context>
@@ -304,31 +304,34 @@ public void testDiscriminatedUnionKeepsChildFields() throws IOException {
- TestUtils.assertFileContains(childPath, "pub identifier: String,");
- TestUtils.assertFileContains(childPath,
- "pub fn new(r#type: String, message: String, identifier: String) -> ObjectExists {");
+ // not serde's internally tagged enum: the child reads its own discriminator, the tag is written once
+ TestUtils.assertFileNotContains(unionPath, "#[serde(tag");
+ TestUtils.assertFileContains(unionPath, "Self::ObjectExists(inner) => (\"ObjectExists\", serde_json::to_value(inner)),");
</file context>
There was a problem hiding this comment.
The hand-written serde was withdrawn in 1c0da30; unions derive #[serde(tag)] again. Partly moot: the claim the comment made is gone. Runtime checks are cargo run round-trips on the generated fixture, listed in the description. The codegen test stays a source-text test, like the rest of RustClientCodegenTest.
…child" This reverts commit 881bd64. The hand-written Serialize/Deserialize through serde_json::Value tied the unions to JSON, doubled the (de)serialization work and did not round-trip nested unions. Unions derive #[serde(tag)] again. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An enum-typed discriminator on a mapped child generated `skip_serializing_if = "String::is_empty"` on an enum field (E0308): it now gets `default` only. A union whose mapping names the base itself would wrap itself (E0275 once it is serialized): it keeps master's inline variants and its children are left untouched. Also mark only `vars` (the only list the template reads) and drop the unreachable classname fallback; shorter comments. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
1 existing issue remains and no new issues found across 26 files (changes from recent commits).
Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
With an enum-typed discriminator (inline or a $ref to an enum schema) the
newtype variant writes the tag twice on the way out, and the child's own
copy keeps the enum's default: {"petType":"Dog","bark":true} came back as
{"petType":"Dog","petType":"Cat","bark":true}. Flag those unions like the
self-mapped ones, so they keep master's inline variants and their children
are left unmarked, identical to master.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ted-union-child-fields # Conflicts: # modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustClientCodegenTest.java
|
rust tests passed via #25133 |
A schema with a
discriminatorand mapped children generates an internally tagged enum whose variants are inline structs built from the parent's vars, so every child-specific field is dropped (ObjectExists { message }losesidentifier). Thetest-duplicatessample even mixed vars across models.Fix
Newtype variants wrapping the mapped models, boxed like the oneOf variants, with the derived
#[serde(tag)]kept:The variant is named by the uniquified
modelNameand wraps the model's real classname.serde consumes the tag before the child deserializes, so a mapped child's discriminator field is marked and defaulted, and skipped while unset:
Option::is_noneif nullable,String::is_emptyif a string. Standalone use of the child is unchanged.Two kinds of union keep master's inline variants, children untouched:
$ref) would be written twice, the child's copy holding the enum's default ("petType":"Dog","petType":"Cat").Verification
RustClientCodegenTest#testDiscriminatedUnionKeepsChildFields: string, nullable, inline-enum, enum-ref and self-mapped discriminators.cargo checkof the fixture;cargo runwire → union → wire keeps child fields and writes one tag. Enum-discriminated output is byte-identical to master.oneOfandtest-duplicates(hyper + reqwest) unions become newtypes;oneOfchildren gain the@typeattribute. All build.Known gaps
new()and wrapped in the union writes the tag twice ({"type":"ObjectExists","type":"ObjectExists",...}).PR checklist
./bin/generate-samples.sh ./bin/configs/rust-*).Summary by cubic
Fixes Rust client generation for schemas with a discriminator and mapped children so enum variants keep their child-specific fields. Variants were inline structs built from the parent's vars, silently dropping every field the child added; they now wrap the mapped model in a boxed newtype variant.
Behavior
Option::is_nonefor nullable,String::is_emptyfor non-nullable strings), keeping the tag the only occurrence on the wire.new()still writes the tag twice through the union.Testing
discriminated-union.yamlfixture and a regression test pinning the derived#[serde(tag)].Written for commit daa9c0d. Summary will update on new commits.
Generated with Claude Code