Skip to content

Fix C# GenericHost oneOf serialization for referenced models - #24674

Open
madhus1218 wants to merge 6 commits into
OpenAPITools:masterfrom
madhus1218:fix-csharp-generichost-oneof-serialization
Open

Fix C# GenericHost oneOf serialization for referenced models#24674
madhus1218 wants to merge 6 commits into
OpenAPITools:masterfrom
madhus1218:fix-csharp-generichost-oneof-serialization

Conversation

@madhus1218

@madhus1218 madhus1218 commented Aug 11, 2026

Copy link
Copy Markdown

What does this PR do?

Fixes C# GenericHost serialization for oneOf schemas that contain referenced model types.

Previously, when a oneOf value was a referenced object, its properties were not written into the serialized JSON output. This update adds handling in JsonConverter.mustache so referenced oneOf models delegate to their generated WriteProperties(...) method while preserving sibling properties on the parent model.

Testing

  • Added regression coverage for serializing a referenced oneOf model.

  • Added coverage for a referenced oneOf model with a sibling property.

  • Regenerated the C# GenericHost OneOf sample.

  • Verified the generated .NET 8 sample tests:

    • 14 passed
    • 1 skipped
    • 0 failed
  • Verified CSharpClientCodegenTest:

    • 11 passed
    • 0 failed
  • Maven reactor build completed successfully.

Fixes #24398

PR checklist

  • Read the contribution guidelines.
  • Added tests covering the change.
  • Regenerated the affected sample.
  • Verified targeted C# generator tests pass.

Summary by cubic

Fixes C# GenericHost JSON handling for oneOf when the selected type is a referenced model, a primitive, or a container. Serialized referenced oneOf objects now write their properties, primitives and containers serialize as raw values, and sibling properties are preserved. Deserialization now accepts primitive oneOf values instead of throwing for non-object or array input.

  • Bug Fixes
    • Delegate referenced oneOf models to their WriteProperties(...) method.
    • Serialize primitive and container oneOf values without an object wrapper.
    • Read primitive oneOf values directly instead of rejecting non-object or array input.
    • Preserve sibling properties on the parent model when serializing.
    • Add regression tests and regenerate affected samples.

Written for commit f4118dc. Summary will update on new commits.

Review in cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 3 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

@wing328

wing328 commented Aug 11, 2026

Copy link
Copy Markdown
Member

thanks for the PR

cc @devhl-labs

@Mattias-Sehlstedt

Copy link
Copy Markdown
Contributor

To better showcase that the introduced functionality handles the scenario in the issue I would suggest that the specification is modified/extended to have an inline oneOf. The currently generated code seems to rely a lot on the fact that Fruit binds the oneOfs to each other through the model structure as per

components:
  schemas:
    fruit:
      example:
        color: color
      oneOf:
      - $ref: "#/components/schemas/apple"
      - $ref: "#/components/schemas/banana"
      - $ref: "#/components/schemas/orange"

while the example schema provided has it as an inline oneOf for a lone property.

I would also argue that it would be beneficial to have a test case for the Union too, since I would not be surprised if there was some Codegen logic that heavily modifies these types of oneOf to make it possible to handle them more easily (from what I know too it is extremely rare for a generator to support it, so it might be that it is more complex than currently thought).

The CSharp generator is also currently documented as not supporting Union nor oneOf, should that be updated with this? To my understanding the current oneOf logic existing for the generator is purely designed towards having a clear discriminator, and thus why the feature is not marked as implemented.

@devhl-labs

Copy link
Copy Markdown
Contributor

I'll review when the samples are up to date.

@NishchhalTenics

NishchhalTenics commented Aug 18, 2026

Copy link
Copy Markdown

FYI the issue #24398
Is not just for referenced models which the Pull Request states, it exists for primitive types as well, and primitive with inline enum combination, so I think this partially closes the issue.

And also thanks for working on this!

@wing328 wing328 added this to the 7.25.0 milestone Aug 19, 2026
@wing328

wing328 commented Aug 19, 2026

Copy link
Copy Markdown
Member

updated samples in #24735

@wing328 wing328 modified the milestones: 7.25.0, 7.26.0 Aug 24, 2026
@devhl-labs

Copy link
Copy Markdown
Contributor

Thank you wing for building the samples. @madhus1218 Can you make two corrections? Particularly the first of these two issues before we merge.

Thanks for the fix — the overall approach of splitting primitive and model oneOf serialization around WriteStartObject() is correct.

Two concerns:


1. Converter instantiation is inconsistent with the rest of the template

The new model path uses new {{baseType}}JsonConverter():

{{baseType}}JsonConverter {{#lambda.camelcase_sanitize_param}}{{baseType}}JsonConverter{{/lambda.camelcase_sanitize_param}} = new {{baseType}}JsonConverter();

Every other non-primitive path in this template retrieves the converter from the registered JsonSerializerOptions instead:

{{baseType}}JsonConverter {{#lambda.camelcase_sanitize_param}}{{baseType}}JsonConverter{{/lambda.camelcase_sanitize_param}} = ({{baseType}}JsonConverter) jsonSerializerOptions.Converters.First(c => c.CanConvert({{#lambda.camelcase_sanitize_param}}{{classname}}{{/lambda.camelcase_sanitize_param}}.{{name}}.GetType()));

In the generichost pattern, converters are registered through DI and may carry constructor dependencies or configured options. Calling new bypasses that — it will produce incorrect behavior or a runtime error for any converter that lacks a parameterless constructor. Please align with the existing pattern.


2. Container oneOf variants are silently skipped

The {{^isContainer}} guard correctly excludes arrays/maps from the WriteProperties delegation, but there is no fallback for them. If a oneOf schema includes a container variant (e.g. oneOf: [array<string>, SomeModel]), that variant's value will be dropped during serialization with no error. This is likely an edge case, but it would be good to either handle it or leave a comment acknowledging the limitation.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 issue found across 100 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="samples/client/petstore/csharp/generichost/net10/OneOf/src/Org.OpenAPITools/Model/Fruit.cs">

<violation number="1" location="samples/client/petstore/csharp/generichost/net10/OneOf/src/Org.OpenAPITools/Model/Fruit.cs:246">
P2: When more than one oneOf branch is assigned, these independent `if` statements serialize every branch, producing JSON that violates the oneOf contract and loses data when read back. Serialize only one selected branch or reject instances with multiple branches set.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

appleJsonConverter.WriteProperties(writer, fruit.Apple, jsonSerializerOptions);
}

if (fruit.Banana != null)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: When more than one oneOf branch is assigned, these independent if statements serialize every branch, producing JSON that violates the oneOf contract and loses data when read back. Serialize only one selected branch or reject instances with multiple branches set.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At samples/client/petstore/csharp/generichost/net10/OneOf/src/Org.OpenAPITools/Model/Fruit.cs, line 246:

<comment>When more than one oneOf branch is assigned, these independent `if` statements serialize every branch, producing JSON that violates the oneOf contract and loses data when read back. Serialize only one selected branch or reject instances with multiple branches set.</comment>

<file context>
@@ -237,6 +237,23 @@ public override Fruit Read(ref Utf8JsonReader utf8JsonReader, Type typeToConvert
+                appleJsonConverter.WriteProperties(writer, fruit.Apple, jsonSerializerOptions);
+            }
+
+            if (fruit.Banana != null)
+            {
+                BananaJsonConverter bananaJsonConverter = (BananaJsonConverter) jsonSerializerOptions.Converters.First(c => c.CanConvert(fruit.Banana.GetType()));
</file context>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

20 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="samples/client/petstore/csharp/generichost/net8/FormModels/src/Org.OpenAPITools/Model/PolymorphicProperty.cs">

<violation number="1" location="samples/client/petstore/csharp/generichost/net8/FormModels/src/Org.OpenAPITools/Model/PolymorphicProperty.cs:158">
P1: When a scalar `PolymorphicProperty` is nested in another JSON value, the final loop advances the original reader beyond the value because the scalar candidates used copied readers. Skip this container-scanning loop for scalar inputs so the converter returns with the reader on the scalar token.</violation>
</file>

<file name="samples/client/petstore/csharp/generichost/net8/FormModels/src/Org.OpenAPITools/Model/MixedOneOfContent.cs">

<violation number="1" location="samples/client/petstore/csharp/generichost/net8/FormModels/src/Org.OpenAPITools/Model/MixedOneOfContent.cs:174">
P1: When a primitive `oneOf` is nested in an object or array, this branch leaves `utf8JsonReader` on the scalar, then the unconditional scan below advances into the containing value. It can consume sibling properties before the parent converter reads them; return after selecting the scalar candidate or restrict that scan to object/array inputs.</violation>
</file>

<file name="samples/client/petstore/csharp/generichost/net8/FormModels/src/Org.OpenAPITools/Model/FruitReq.cs">

<violation number="1" location="samples/client/petstore/csharp/generichost/net8/FormModels/src/Org.OpenAPITools/Model/FruitReq.cs:119">
P2: When a scalar value is supplied for this model-only `oneOf`, both object deserializations fail and the final reader loop scans unrelated sibling JSON before throwing. Restrict this branch to `oneOf` schemas with a primitive alternative, or retain the initial object/array guard for this model.</violation>
</file>

<file name="samples/client/petstore/csharp/generichost/net9/FormModels/src/Org.OpenAPITools/Model/MixedOneOfContent.cs">

<violation number="1" location="samples/client/petstore/csharp/generichost/net9/FormModels/src/Org.OpenAPITools/Model/MixedOneOfContent.cs:174">
P1: When `MixedOneOfContent` contains a primitive and is nested in another object, this branch leaves the original reader on the primitive, but the cleanup loop then reads through the containing object. Return as soon as a primitive candidate succeeds, or skip that cleanup loop for non-container tokens, so the caller can process following properties.</violation>
</file>

<file name="samples/client/petstore/csharp/generichost/net9/FormModels/src/Org.OpenAPITools/Model/PolymorphicProperty.cs">

<violation number="1" location="samples/client/petstore/csharp/generichost/net9/FormModels/src/Org.OpenAPITools/Model/PolymorphicProperty.cs:158">
P1: When a scalar `PolymorphicProperty` is nested in another JSON value, this branch consumes the enclosing object or array before returning. Return the matched scalar from this branch, or run the shared scan only for container start tokens.</violation>

<violation number="2" location="samples/client/petstore/csharp/generichost/net9/FormModels/src/Org.OpenAPITools/Model/PolymorphicProperty.cs:167">
P2: For a JSON number, `TryDeserialize<Object>` succeeds with a `JsonElement`, so `Read` returns an object variant even though the schema has no numeric `oneOf` branch. Do not attempt the object candidate in the scalar-token branch.</violation>
</file>

<file name="samples/client/petstore/csharp/generichost/net9/FormModels/src/Org.OpenAPITools/Model/FruitReq.cs">

<violation number="1" location="samples/client/petstore/csharp/generichost/net9/FormModels/src/Org.OpenAPITools/Model/FruitReq.cs:119">
P1: When this converter is generated for a scalar-capable oneOf, the new branch leaves `utf8JsonReader` on the scalar, but the trailing loop still consumes the parent’s remaining properties. Skip that object/array traversal for scalar tokens so nested deserialization does not lose siblings.</violation>
</file>

<file name="modules/openapi-generator/src/test/resources/3_0/oneOf.yaml">

<violation number="1" location="modules/openapi-generator/src/test/resources/3_0/oneOf.yaml:30">
P3: The two newly added schemas `inlineOneOfModel` and `union` are not referenced by any test. A repository-wide search shows the only consumers of this shared fixture (`DefaultCodegenTest`, `RubyClientCodegenTest`, `JavaClientCodegenTest`, `ProtobufSchemaCodegenTest`, `Swift6ClientCodegenTest`) all target only the pre-existing `fruit` schema, and no test or sample references `inlineOneOfModel` or `union`. They therefore add no regression coverage (the PR's referenced-model tests use `fruit`) and leave dead content in a shared test resource. Add a test that exercises these schemas, or drop them.</violation>
</file>

<file name="samples/client/petstore/csharp/generichost/net8/FormModels/src/Org.OpenAPITools/Model/Mammal.cs">

<violation number="1" location="samples/client/petstore/csharp/generichost/net8/FormModels/src/Org.OpenAPITools/Model/Mammal.cs:146">
P2: The regenerated Mammal.cs dropped the Read() token-type guard, but the current JsonConverter.mustache still emits it for oneOf models with no primitive members (Mammal's Pig/Whale/Zebra are all references, so oneOfHasPrimitiveType is false), and the sibling Animal.cs (lines 134-135) still has it. The sample no longer matches the template. Regenerate it; removing the guard also changes malformed-input handling from an early JsonException to a late ArgumentException("Property is required for class Mammal.").</violation>
</file>

<file name="samples/client/petstore/csharp/generichost/net8/FormModels/src/Org.OpenAPITools/Model/Shape.cs">

<violation number="1" location="samples/client/petstore/csharp/generichost/net8/FormModels/src/Org.OpenAPITools/Model/Shape.cs:131">
P2: The removed token-type guard makes this regenerated sample inconsistent with JsonConverter.mustache, which still emits it for oneOf models with non-primitive types (Shape's oneOf is Triangle/Quadrilateral, so oneOfHasPrimitiveType is false and the guard is regenerated). Regenerating this sample would restore the guard, and without it the discriminator/main read loops never hit their StartObject/StartArray break conditions on malformed input, so they consume all remaining tokens instead of throwing a JsonException. Restore the guard here (or update the template to remove it for this case if the removal is intentional).</violation>
</file>

<file name="samples/client/petstore/csharp/generichost/net9/FormModels/src/Org.OpenAPITools/Model/Mammal.cs">

<violation number="1" location="samples/client/petstore/csharp/generichost/net9/FormModels/src/Org.OpenAPITools/Model/Mammal.cs:146">
P2: This guard should not be removed for Mammal. Its oneOf members (whale, zebra, Pig) are all referenced model types, so JsonConverter.mustache computes oneOfHasPrimitiveType = false and still emits the token-type guard for model-only oneOf schemas. Without it, deserializing a non-object/array value for a model-only oneOf no longer throws the intended JsonException at the entry; it falls through the loops and fails later with a misleading 'Property is required for class Mammal' ArgumentException. Regenerate this sample from the updated template rather than deleting the guard.</violation>
</file>

<file name="samples/client/petstore/csharp/generichost/net8/FormModels/src/Org.OpenAPITools/Model/Triangle.cs">

<violation number="1" location="samples/client/petstore/csharp/generichost/net8/FormModels/src/Org.OpenAPITools/Model/Triangle.cs:146">
P2: Triangle.cs is now out of sync with JsonConverter.mustache. The template emits `bool oneOfHasPrimitiveType = false;` and `if (!oneOfHasPrimitiveType && utf8JsonReader.TokenType != JsonTokenType.StartObject && utf8JsonReader.TokenType != JsonTokenType.StartArray) throw new JsonException();` for every oneOf model, and Triangle's oneOf contains only model types, so regenerating this sample reintroduces the guard you just deleted. Regenerate the sample so the checked-in file matches the generator output (the maintainers already gate review on samples being up to date).</violation>
</file>

<file name="samples/client/petstore/csharp/generichost/net9/FormModels/src/Org.OpenAPITools/Model/Quadrilateral.cs">

<violation number="1" location="samples/client/petstore/csharp/generichost/net9/FormModels/src/Org.OpenAPITools/Model/Quadrilateral.cs:131">
P2: This sample no longer matches the JsonConverter.mustache template, which still emits the StartObject/StartArray guard, and the sibling Animal.cs (same FormModels sample, same discriminated oneOf) still has it. Removing the guard also changes error behavior: scalar/non-object input no longer throws JsonException up front; with a scalar starting token the discriminator and main loops never hit their EndObject/EndArray break, so they run to exhaustion and the code falls through to a misleading `ArgumentException("Property is required for class Quadrilateral.")`. Either regenerate the sample from the current template so the guard is restored, or, if the guard removal is intentional, apply the same removal to the template so the generated code stays consistent.</violation>
</file>

<file name="samples/client/petstore/csharp/generichost/net8/FormModels/src/Org.OpenAPITools/Model/OneOfString.cs">

<violation number="1" location="samples/client/petstore/csharp/generichost/net8/FormModels/src/Org.OpenAPITools/Model/OneOfString.cs:106">
P2: After removing the StartObject/StartArray guard, Read() still has no way to parse the primitive value that Write() now emits (Write serializes a bare string for the oneOf string case). varString is never assigned, so Read() always throws JsonException, and when a primitive token appears nested inside a larger object the loop break conditions never match and Read() consumes the remainder of the document. JsonConverter.mustache already emits a oneOfHasPrimitiveType + TryDeserialize branch for this model; regenerate this sample so Read() can deserialize what Write() produces.</violation>
</file>

<file name="samples/client/petstore/csharp/generichost/net9/FormModels/src/Org.OpenAPITools/Model/Shape.cs">

<violation number="1" location="samples/client/petstore/csharp/generichost/net9/FormModels/src/Org.OpenAPITools/Model/Shape.cs:131">
P2: The removed guard does not match the current template: JsonConverter.mustache still emits the StartObject/StartArray check for Shape (a discriminator model with no composedSchemas.oneOf, `{{^composedSchemas.oneOf}}` branch), so regenerating with the restored template would re-add it. This leaves the regenerated OneOf sample inconsistent with the template and with its siblings — net9/Petstore, net9/NullReferenceTypes, and net10/net4.7 FormModels Shape.cs all retain the guard, while only net8/net9 FormModels dropped it. Re-add the guard (or regenerate the FormModels samples from the restored template). Without it, a non-object/array JSON value for Shape no longer throws a clean JsonException up front and instead falls through to an ArgumentException for the missing required `shapeType`.</violation>
</file>

<file name="samples/client/petstore/csharp/generichost/net9/FormModels/src/Org.OpenAPITools/Model/Pig.cs">

<violation number="1" location="samples/client/petstore/csharp/generichost/net9/FormModels/src/Org.OpenAPITools/Model/Pig.cs:131">
P2: This removes the token-type guard from Pig's Read, but the current JsonConverter.mustache still emits that guard for any oneOf without a primitive type (Pig's oneOf is only BasquePig/DanishPig, so oneOfHasPrimitiveType stays false). The regenerated sample would put the guard back, and the sibling sample MixedOneOf.cs:116 still has it, so this hand-edit leaves the generated sample out of sync with the template and would be undone (or fail the sample-up-to-date check) on the next regeneration. Regenerate the C# GenericHost samples from the current template instead of editing Pig.cs directly.</violation>
</file>

<file name="samples/client/petstore/csharp/generichost/net8/FormModels/src/Org.OpenAPITools/Model/Quadrilateral.cs">

<violation number="1" location="samples/client/petstore/csharp/generichost/net8/FormModels/src/Org.OpenAPITools/Model/Quadrilateral.cs:131">
P2: The net8 sample's Read() guard removal does not match the JsonConverter.mustache template or the other framework samples. For a oneOf whose members are all models (Quadrilateral: SimpleQuadrilateral + ComplexQuadrilateral), oneOfHasPrimitiveType is false, so the template still emits `if (!oneOfHasPrimitiveType && TokenType != StartObject && TokenType != StartArray) throw new JsonException();`. net10, net4.8, and net4.7 all retain this guard; only net8/net9 dropped it. Regenerating net8 from the current template re-adds the guard, so this sample does not reflect the generated output. Re-add the guard (or regenerate the sample) so the sample is consistent with the template; without it, deserializing a non-object/array value now scans the entire stream and throws ArgumentException instead of JsonException.</violation>
</file>

<file name="samples/client/petstore/csharp/generichost/net9/FormModels/src/Org.OpenAPITools/Model/OneOfString.cs">

<violation number="1" location="samples/client/petstore/csharp/generichost/net9/FormModels/src/Org.OpenAPITools/Model/OneOfString.cs:106">
P2: Removing the token-type guard here is ineffective because the corresponding primitive-token handling is missing. OneOfString.Write emits a bare JSON string when String is set, but OneOfString.Read has no path to read one: the while loop only breaks on EndObject/EndArray, varString is never assigned from a primitive token, and the method always throws JsonException. The JsonConverter.mustache template pairs this guard removal with a ClientUtils.TryDeserialize branch for non-object/array tokens (see the regenerated PolymorphicProperty.cs, lines 151-169); this file only has the removal, so it is stale relative to the current template. Regenerate this sample from the current template so the primitive oneOf can round-trip.</violation>
</file>

<file name="samples/client/petstore/csharp/generichost/net8/FormModels/src/Org.OpenAPITools/Model/NullableShape.cs">

<violation number="1" location="samples/client/petstore/csharp/generichost/net8/FormModels/src/Org.OpenAPITools/Model/NullableShape.cs:131">
P2: The generated Read() no longer guards against non-object/non-array input, but JsonConverter.mustache still emits that guard for oneOf schemas whose members are all models (the `!oneOfHasPrimitiveType` branch). NullableShape's oneOf members (Triangle, Quadrilateral) are both models, so the template would still produce the guard and the `oneOfHasPrimitiveType` declaration, which this sample no longer contains. As a result, a malformed primitive token now falls through to `throw new ArgumentException("Property is required for class NullableShape.")` instead of a `JsonException`, and the sample is out of sync with the restored template. Regenerate this sample from the current template so the model-only oneOf keeps its input guard.</violation>
</file>

<file name="samples/client/petstore/csharp/generichost/net8/FormModels/src/Org.OpenAPITools/Model/Pig.cs">

<violation number="1" location="samples/client/petstore/csharp/generichost/net8/FormModels/src/Org.OpenAPITools/Model/Pig.cs:131">
P2: Removing the StartObject/StartArray guard makes this generated sample diverge from the template that produces it. JsonConverter.mustache still emits `if (utf8JsonReader.TokenType != JsonTokenType.StartObject && ...) throw new JsonException();` for every `{{^composedSchemas.oneOf}}` model (line 62), and the identical Pig model in the sibling sample net8/Petstore/.../Pig.cs:131 still contains the guard. Since Pig is not a `composedSchemas.oneOf` model, regenerating this sample from the committed template would restore the removed check, so this edit has no lasting effect and the FormModels sample is out of sync with the template. Restore the guard to match the template, or if the guard should be dropped for referenced oneOf models, change the template and regenerate all samples.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic


Utf8JsonReader utf8JsonReaderOneOf = utf8JsonReader;
while (utf8JsonReaderOneOf.Read())
if (startingTokenType != JsonTokenType.StartObject && startingTokenType != JsonTokenType.StartArray)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: When a scalar PolymorphicProperty is nested in another JSON value, the final loop advances the original reader beyond the value because the scalar candidates used copied readers. Skip this container-scanning loop for scalar inputs so the converter returns with the reader on the scalar token.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At samples/client/petstore/csharp/generichost/net8/FormModels/src/Org.OpenAPITools/Model/PolymorphicProperty.cs, line 158:

<comment>When a scalar `PolymorphicProperty` is nested in another JSON value, the final loop advances the original reader beyond the value because the scalar candidates used copied readers. Skip this container-scanning loop for scalar inputs so the converter returns with the reader on the scalar token.</comment>

<file context>
@@ -148,38 +148,52 @@ public override PolymorphicProperty Read(ref Utf8JsonReader utf8JsonReader, Type
 
-            Utf8JsonReader utf8JsonReaderOneOf = utf8JsonReader;
-            while (utf8JsonReaderOneOf.Read())
+            if (startingTokenType != JsonTokenType.StartObject && startingTokenType != JsonTokenType.StartArray)
             {
-                if (startingTokenType == JsonTokenType.StartObject && utf8JsonReaderOneOf.TokenType == JsonTokenType.EndObject && currentDepth == utf8JsonReaderOneOf.CurrentDepth)
</file context>


Utf8JsonReader utf8JsonReaderOneOf = utf8JsonReader;
while (utf8JsonReaderOneOf.Read())
if (startingTokenType != JsonTokenType.StartObject && startingTokenType != JsonTokenType.StartArray)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: When a primitive oneOf is nested in an object or array, this branch leaves utf8JsonReader on the scalar, then the unconditional scan below advances into the containing value. It can consume sibling properties before the parent converter reads them; return after selecting the scalar candidate or restrict that scan to object/array inputs.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At samples/client/petstore/csharp/generichost/net8/FormModels/src/Org.OpenAPITools/Model/MixedOneOfContent.cs, line 174:

<comment>When a primitive `oneOf` is nested in an object or array, this branch leaves `utf8JsonReader` on the scalar, then the unconditional scan below advances into the containing value. It can consume sibling properties before the parent converter reads them; return after selecting the scalar candidate or restrict that scan to object/array inputs.</comment>

<file context>
@@ -174,31 +171,51 @@ public override MixedOneOfContent Read(ref Utf8JsonReader utf8JsonReader, Type t
 
-            Utf8JsonReader utf8JsonReaderOneOf = utf8JsonReader;
-            while (utf8JsonReaderOneOf.Read())
+            if (startingTokenType != JsonTokenType.StartObject && startingTokenType != JsonTokenType.StartArray)
             {
-                if (startingTokenType == JsonTokenType.StartObject && utf8JsonReaderOneOf.TokenType == JsonTokenType.EndObject && currentDepth == utf8JsonReaderOneOf.CurrentDepth)
</file context>

Comment on lines +174 to +190
if (startingTokenType != JsonTokenType.StartObject && startingTokenType != JsonTokenType.StartArray)
{
if (startingTokenType == JsonTokenType.StartObject && utf8JsonReaderOneOf.TokenType == JsonTokenType.EndObject && currentDepth == utf8JsonReaderOneOf.CurrentDepth)
break;
Utf8JsonReader utf8JsonReaderString = utf8JsonReader;
ClientUtils.TryDeserialize<string>(ref utf8JsonReaderString, jsonSerializerOptions, out varString);

if (startingTokenType == JsonTokenType.StartArray && utf8JsonReaderOneOf.TokenType == JsonTokenType.EndArray && currentDepth == utf8JsonReaderOneOf.CurrentDepth)
break;
Utf8JsonReader utf8JsonReaderBool = utf8JsonReader;
ClientUtils.TryDeserialize<bool?>(ref utf8JsonReaderBool, jsonSerializerOptions, out varBool);

Utf8JsonReader utf8JsonReaderInt = utf8JsonReader;
ClientUtils.TryDeserialize<int?>(ref utf8JsonReaderInt, jsonSerializerOptions, out varInt);

if (utf8JsonReaderOneOf.TokenType == JsonTokenType.PropertyName && currentDepth == utf8JsonReaderOneOf.CurrentDepth - 1)
Utf8JsonReader utf8JsonReaderDecimal = utf8JsonReader;
ClientUtils.TryDeserialize<decimal?>(ref utf8JsonReaderDecimal, jsonSerializerOptions, out varDecimal);

Utf8JsonReader utf8JsonReaderMixedSubId = utf8JsonReader;
ClientUtils.TryDeserialize<MixedSubId>(ref utf8JsonReaderMixedSubId, jsonSerializerOptions, out mixedSubId);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: When MixedOneOfContent contains a primitive and is nested in another object, this branch leaves the original reader on the primitive, but the cleanup loop then reads through the containing object. Return as soon as a primitive candidate succeeds, or skip that cleanup loop for non-container tokens, so the caller can process following properties.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At samples/client/petstore/csharp/generichost/net9/FormModels/src/Org.OpenAPITools/Model/MixedOneOfContent.cs, line 174:

<comment>When `MixedOneOfContent` contains a primitive and is nested in another object, this branch leaves the original reader on the primitive, but the cleanup loop then reads through the containing object. Return as soon as a primitive candidate succeeds, or skip that cleanup loop for non-container tokens, so the caller can process following properties.</comment>

<file context>
@@ -174,31 +171,51 @@ public override MixedOneOfContent Read(ref Utf8JsonReader utf8JsonReader, Type t
 
-            Utf8JsonReader utf8JsonReaderOneOf = utf8JsonReader;
-            while (utf8JsonReaderOneOf.Read())
+            if (startingTokenType != JsonTokenType.StartObject && startingTokenType != JsonTokenType.StartArray)
             {
-                if (startingTokenType == JsonTokenType.StartObject && utf8JsonReaderOneOf.TokenType == JsonTokenType.EndObject && currentDepth == utf8JsonReaderOneOf.CurrentDepth)
</file context>
Suggested change
if (startingTokenType != JsonTokenType.StartObject && startingTokenType != JsonTokenType.StartArray)
{
if (startingTokenType == JsonTokenType.StartObject && utf8JsonReaderOneOf.TokenType == JsonTokenType.EndObject && currentDepth == utf8JsonReaderOneOf.CurrentDepth)
break;
Utf8JsonReader utf8JsonReaderString = utf8JsonReader;
ClientUtils.TryDeserialize<string>(ref utf8JsonReaderString, jsonSerializerOptions, out varString);
if (startingTokenType == JsonTokenType.StartArray && utf8JsonReaderOneOf.TokenType == JsonTokenType.EndArray && currentDepth == utf8JsonReaderOneOf.CurrentDepth)
break;
Utf8JsonReader utf8JsonReaderBool = utf8JsonReader;
ClientUtils.TryDeserialize<bool?>(ref utf8JsonReaderBool, jsonSerializerOptions, out varBool);
Utf8JsonReader utf8JsonReaderInt = utf8JsonReader;
ClientUtils.TryDeserialize<int?>(ref utf8JsonReaderInt, jsonSerializerOptions, out varInt);
if (utf8JsonReaderOneOf.TokenType == JsonTokenType.PropertyName && currentDepth == utf8JsonReaderOneOf.CurrentDepth - 1)
Utf8JsonReader utf8JsonReaderDecimal = utf8JsonReader;
ClientUtils.TryDeserialize<decimal?>(ref utf8JsonReaderDecimal, jsonSerializerOptions, out varDecimal);
Utf8JsonReader utf8JsonReaderMixedSubId = utf8JsonReader;
ClientUtils.TryDeserialize<MixedSubId>(ref utf8JsonReaderMixedSubId, jsonSerializerOptions, out mixedSubId);
}
if (startingTokenType != JsonTokenType.StartObject && startingTokenType != JsonTokenType.StartArray)
{
Utf8JsonReader utf8JsonReaderString = utf8JsonReader;
if (ClientUtils.TryDeserialize<string>(ref utf8JsonReaderString, jsonSerializerOptions, out varString))
return new MixedOneOfContent(varString);
Utf8JsonReader utf8JsonReaderBool = utf8JsonReader;
if (ClientUtils.TryDeserialize<bool?>(ref utf8JsonReaderBool, jsonSerializerOptions, out varBool))
return new MixedOneOfContent(varBool.Value);
Utf8JsonReader utf8JsonReaderInt = utf8JsonReader;
if (ClientUtils.TryDeserialize<int?>(ref utf8JsonReaderInt, jsonSerializerOptions, out varInt))
return new MixedOneOfContent(varInt.Value);
Utf8JsonReader utf8JsonReaderDecimal = utf8JsonReader;
if (ClientUtils.TryDeserialize<decimal?>(ref utf8JsonReaderDecimal, jsonSerializerOptions, out varDecimal))
return new MixedOneOfContent(varDecimal.Value);
Utf8JsonReader utf8JsonReaderMixedSubId = utf8JsonReader;
if (ClientUtils.TryDeserialize<MixedSubId>(ref utf8JsonReaderMixedSubId, jsonSerializerOptions, out mixedSubId))
return new MixedOneOfContent(mixedSubId);
throw new JsonException();
}


Utf8JsonReader utf8JsonReaderOneOf = utf8JsonReader;
while (utf8JsonReaderOneOf.Read())
if (startingTokenType != JsonTokenType.StartObject && startingTokenType != JsonTokenType.StartArray)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: When a scalar PolymorphicProperty is nested in another JSON value, this branch consumes the enclosing object or array before returning. Return the matched scalar from this branch, or run the shared scan only for container start tokens.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At samples/client/petstore/csharp/generichost/net9/FormModels/src/Org.OpenAPITools/Model/PolymorphicProperty.cs, line 158:

<comment>When a scalar `PolymorphicProperty` is nested in another JSON value, this branch consumes the enclosing object or array before returning. Return the matched scalar from this branch, or run the shared scan only for container start tokens.</comment>

<file context>
@@ -148,38 +148,52 @@ public override PolymorphicProperty Read(ref Utf8JsonReader utf8JsonReader, Type
 
-            Utf8JsonReader utf8JsonReaderOneOf = utf8JsonReader;
-            while (utf8JsonReaderOneOf.Read())
+            if (startingTokenType != JsonTokenType.StartObject && startingTokenType != JsonTokenType.StartArray)
             {
-                if (startingTokenType == JsonTokenType.StartObject && utf8JsonReaderOneOf.TokenType == JsonTokenType.EndObject && currentDepth == utf8JsonReaderOneOf.CurrentDepth)
</file context>


Utf8JsonReader utf8JsonReaderOneOf = utf8JsonReader;
while (utf8JsonReaderOneOf.Read())
if (startingTokenType != JsonTokenType.StartObject && startingTokenType != JsonTokenType.StartArray)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: When this converter is generated for a scalar-capable oneOf, the new branch leaves utf8JsonReader on the scalar, but the trailing loop still consumes the parent’s remaining properties. Skip that object/array traversal for scalar tokens so nested deserialization does not lose siblings.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At samples/client/petstore/csharp/generichost/net9/FormModels/src/Org.OpenAPITools/Model/FruitReq.cs, line 119:

<comment>When this converter is generated for a scalar-capable oneOf, the new branch leaves `utf8JsonReader` on the scalar, but the trailing loop still consumes the parent’s remaining properties. Skip that object/array traversal for scalar tokens so nested deserialization does not lose siblings.</comment>

<file context>
@@ -111,30 +111,38 @@ public override FruitReq Read(ref Utf8JsonReader utf8JsonReader, Type typeToConv
 
-            Utf8JsonReader utf8JsonReaderOneOf = utf8JsonReader;
-            while (utf8JsonReaderOneOf.Read())
+            if (startingTokenType != JsonTokenType.StartObject && startingTokenType != JsonTokenType.StartArray)
             {
-                if (startingTokenType == JsonTokenType.StartObject && utf8JsonReaderOneOf.TokenType == JsonTokenType.EndObject && currentDepth == utf8JsonReaderOneOf.CurrentDepth)
</file context>

{
int currentDepth = utf8JsonReader.CurrentDepth;

if (utf8JsonReader.TokenType != JsonTokenType.StartObject && utf8JsonReader.TokenType != JsonTokenType.StartArray)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: The net8 sample's Read() guard removal does not match the JsonConverter.mustache template or the other framework samples. For a oneOf whose members are all models (Quadrilateral: SimpleQuadrilateral + ComplexQuadrilateral), oneOfHasPrimitiveType is false, so the template still emits if (!oneOfHasPrimitiveType && TokenType != StartObject && TokenType != StartArray) throw new JsonException();. net10, net4.8, and net4.7 all retain this guard; only net8/net9 dropped it. Regenerating net8 from the current template re-adds the guard, so this sample does not reflect the generated output. Re-add the guard (or regenerate the sample) so the sample is consistent with the template; without it, deserializing a non-object/array value now scans the entire stream and throws ArgumentException instead of JsonException.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At samples/client/petstore/csharp/generichost/net8/FormModels/src/Org.OpenAPITools/Model/Quadrilateral.cs, line 131:

<comment>The net8 sample's Read() guard removal does not match the JsonConverter.mustache template or the other framework samples. For a oneOf whose members are all models (Quadrilateral: SimpleQuadrilateral + ComplexQuadrilateral), oneOfHasPrimitiveType is false, so the template still emits `if (!oneOfHasPrimitiveType && TokenType != StartObject && TokenType != StartArray) throw new JsonException();`. net10, net4.8, and net4.7 all retain this guard; only net8/net9 dropped it. Regenerating net8 from the current template re-adds the guard, so this sample does not reflect the generated output. Re-add the guard (or regenerate the sample) so the sample is consistent with the template; without it, deserializing a non-object/array value now scans the entire stream and throws ArgumentException instead of JsonException.</comment>

<file context>
@@ -128,9 +128,6 @@ public override Quadrilateral Read(ref Utf8JsonReader utf8JsonReader, Type typeT
-            if (utf8JsonReader.TokenType != JsonTokenType.StartObject && utf8JsonReader.TokenType != JsonTokenType.StartArray)
-                throw new JsonException();
-
             JsonTokenType startingTokenType = utf8JsonReader.TokenType;
 
             Option<string> quadrilateralType = default;
</file context>

{
int currentDepth = utf8JsonReader.CurrentDepth;

if (utf8JsonReader.TokenType != JsonTokenType.StartObject && utf8JsonReader.TokenType != JsonTokenType.StartArray)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Removing the token-type guard here is ineffective because the corresponding primitive-token handling is missing. OneOfString.Write emits a bare JSON string when String is set, but OneOfString.Read has no path to read one: the while loop only breaks on EndObject/EndArray, varString is never assigned from a primitive token, and the method always throws JsonException. The JsonConverter.mustache template pairs this guard removal with a ClientUtils.TryDeserialize branch for non-object/array tokens (see the regenerated PolymorphicProperty.cs, lines 151-169); this file only has the removal, so it is stale relative to the current template. Regenerate this sample from the current template so the primitive oneOf can round-trip.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At samples/client/petstore/csharp/generichost/net9/FormModels/src/Org.OpenAPITools/Model/OneOfString.cs, line 106:

<comment>Removing the token-type guard here is ineffective because the corresponding primitive-token handling is missing. OneOfString.Write emits a bare JSON string when String is set, but OneOfString.Read has no path to read one: the while loop only breaks on EndObject/EndArray, varString is never assigned from a primitive token, and the method always throws JsonException. The JsonConverter.mustache template pairs this guard removal with a ClientUtils.TryDeserialize branch for non-object/array tokens (see the regenerated PolymorphicProperty.cs, lines 151-169); this file only has the removal, so it is stale relative to the current template. Regenerate this sample from the current template so the primitive oneOf can round-trip.</comment>

<file context>
@@ -103,9 +103,6 @@ public override OneOfString Read(ref Utf8JsonReader utf8JsonReader, Type typeToC
-            if (utf8JsonReader.TokenType != JsonTokenType.StartObject && utf8JsonReader.TokenType != JsonTokenType.StartArray)
-                throw new JsonException();
-
             JsonTokenType startingTokenType = utf8JsonReader.TokenType;
 
             string varString = default;
</file context>

{
int currentDepth = utf8JsonReader.CurrentDepth;

if (utf8JsonReader.TokenType != JsonTokenType.StartObject && utf8JsonReader.TokenType != JsonTokenType.StartArray)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: The generated Read() no longer guards against non-object/non-array input, but JsonConverter.mustache still emits that guard for oneOf schemas whose members are all models (the !oneOfHasPrimitiveType branch). NullableShape's oneOf members (Triangle, Quadrilateral) are both models, so the template would still produce the guard and the oneOfHasPrimitiveType declaration, which this sample no longer contains. As a result, a malformed primitive token now falls through to throw new ArgumentException("Property is required for class NullableShape.") instead of a JsonException, and the sample is out of sync with the restored template. Regenerate this sample from the current template so the model-only oneOf keeps its input guard.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At samples/client/petstore/csharp/generichost/net8/FormModels/src/Org.OpenAPITools/Model/NullableShape.cs, line 131:

<comment>The generated Read() no longer guards against non-object/non-array input, but JsonConverter.mustache still emits that guard for oneOf schemas whose members are all models (the `!oneOfHasPrimitiveType` branch). NullableShape's oneOf members (Triangle, Quadrilateral) are both models, so the template would still produce the guard and the `oneOfHasPrimitiveType` declaration, which this sample no longer contains. As a result, a malformed primitive token now falls through to `throw new ArgumentException("Property is required for class NullableShape.")` instead of a `JsonException`, and the sample is out of sync with the restored template. Regenerate this sample from the current template so the model-only oneOf keeps its input guard.</comment>

<file context>
@@ -128,9 +128,6 @@ public override NullableShape Read(ref Utf8JsonReader utf8JsonReader, Type typeT
-            if (utf8JsonReader.TokenType != JsonTokenType.StartObject && utf8JsonReader.TokenType != JsonTokenType.StartArray)
-                throw new JsonException();
-
             JsonTokenType startingTokenType = utf8JsonReader.TokenType;
 
             Option<string> shapeType = default;
</file context>

{
int currentDepth = utf8JsonReader.CurrentDepth;

if (utf8JsonReader.TokenType != JsonTokenType.StartObject && utf8JsonReader.TokenType != JsonTokenType.StartArray)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Removing the StartObject/StartArray guard makes this generated sample diverge from the template that produces it. JsonConverter.mustache still emits if (utf8JsonReader.TokenType != JsonTokenType.StartObject && ...) throw new JsonException(); for every {{^composedSchemas.oneOf}} model (line 62), and the identical Pig model in the sibling sample net8/Petstore/.../Pig.cs:131 still contains the guard. Since Pig is not a composedSchemas.oneOf model, regenerating this sample from the committed template would restore the removed check, so this edit has no lasting effect and the FormModels sample is out of sync with the template. Restore the guard to match the template, or if the guard should be dropped for referenced oneOf models, change the template and regenerate all samples.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At samples/client/petstore/csharp/generichost/net8/FormModels/src/Org.OpenAPITools/Model/Pig.cs, line 131:

<comment>Removing the StartObject/StartArray guard makes this generated sample diverge from the template that produces it. JsonConverter.mustache still emits `if (utf8JsonReader.TokenType != JsonTokenType.StartObject && ...) throw new JsonException();` for every `{{^composedSchemas.oneOf}}` model (line 62), and the identical Pig model in the sibling sample net8/Petstore/.../Pig.cs:131 still contains the guard. Since Pig is not a `composedSchemas.oneOf` model, regenerating this sample from the committed template would restore the removed check, so this edit has no lasting effect and the FormModels sample is out of sync with the template. Restore the guard to match the template, or if the guard should be dropped for referenced oneOf models, change the template and regenerate all samples.</comment>

<file context>
@@ -128,9 +128,6 @@ public override Pig Read(ref Utf8JsonReader utf8JsonReader, Type typeToConvert,
-            if (utf8JsonReader.TokenType != JsonTokenType.StartObject && utf8JsonReader.TokenType != JsonTokenType.StartArray)
-                throw new JsonException();
-
             JsonTokenType startingTokenType = utf8JsonReader.TokenType;
 
             Option<string> className = default;
</file context>

- $ref: '#/components/schemas/banana'
- $ref: '#/components/schemas/orange'

inlineOneOfModel:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: The two newly added schemas inlineOneOfModel and union are not referenced by any test. A repository-wide search shows the only consumers of this shared fixture (DefaultCodegenTest, RubyClientCodegenTest, JavaClientCodegenTest, ProtobufSchemaCodegenTest, Swift6ClientCodegenTest) all target only the pre-existing fruit schema, and no test or sample references inlineOneOfModel or union. They therefore add no regression coverage (the PR's referenced-model tests use fruit) and leave dead content in a shared test resource. Add a test that exercises these schemas, or drop them.

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/resources/3_0/oneOf.yaml, line 30:

<comment>The two newly added schemas `inlineOneOfModel` and `union` are not referenced by any test. A repository-wide search shows the only consumers of this shared fixture (`DefaultCodegenTest`, `RubyClientCodegenTest`, `JavaClientCodegenTest`, `ProtobufSchemaCodegenTest`, `Swift6ClientCodegenTest`) all target only the pre-existing `fruit` schema, and no test or sample references `inlineOneOfModel` or `union`. They therefore add no regression coverage (the PR's referenced-model tests use `fruit`) and leave dead content in a shared test resource. Add a test that exercises these schemas, or drop them.</comment>

<file context>
@@ -1,46 +1,68 @@
+        - $ref: '#/components/schemas/banana'
+        - $ref: '#/components/schemas/orange'
+
+    inlineOneOfModel:
+      title: inlineOneOfModel
+      type: object
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG][CSHARP][GENERICHOST] oneOf Serialization: Empty WriteProperties

6 participants