[csharp][generichost] Serialize union members that are not merged objects
Fixes #23261, fixes #23529, fixes #23693, fixes #25154.
Summary
The generichost composed-schema converter assumes every oneOf/anyOf value is a JSON object whose members' properties get merged. These four issues all come from that assumption:
| Issue | Shape | Master |
|---|---|---|
| #23261 | anyOf/oneOf with a string or enum member | Read throws on every string |
| #23529 | anyOf: [Foo, Foo[]] | Doesn't compile |
| #23693 | oneOf of objects, no discriminator | Write emits {} |
| #25154 | anyOf with a discriminator | Write emits {} |
The converter now treats enum-ref, primitive and container members as values, and every other member as an object:
Read
- throw unless the token is an object or array
+ object token: read members as before
+ any other token: TryDeserialize each value member (anyOf throws if none match)
Write
+ value member set: serialize it and return
WriteStartObject
discriminated oneOf: merge object members (unchanged)
- anyOf without a discriminator: merge object members
+ oneOf without a discriminator, and every anyOf: merge object members
write the union's own properties
Object members are still merged rather than serialized directly, so a union with its own properties (petstore Fruit.color) keeps them.
This also removes two broken anyOf write paths that the samples show: primitives written as {"ClassName": value}, and a body-less if for list members that made WriteProperties conditional (AnyOfStringArrayOfString).
Evidence
New sample latest/UnionMembers (spec 3_1/csharp/union-members.yaml, added to samples-dotnet10.yaml) with hand-written round-trip tests in UnionMemberSerializationTests.cs:
- Before (master templates): the sample doesn't compile because of
FooOrList. Without it, 13 of 17 cases fail. The 4 that pass are the merged-objectanyOfand the three "no member matches" cases, which should pass on both. - After: all 21 cases pass.
{"status":"frozen"} -> Status null, String "frozen", round-trips
{"fooOrList":[{"id":1},{"id":2}]} -> List set, Foo null, round-trips
{"entry":{"id":"o1","resourceType":...}} -> round-trips (was {"entry":{}})
{"fruit":{"color":"red","cultivar":...}} -> round-trips, keeps color
{"status":5} -> JsonException (unchanged)
Also verified:
./bin/generate-samples.sh bin/configs/csharp-generichost*.yaml: every changed sample builds. Tests pass forlatest,net8,net9,net10andstandard2.0.net4.7/net4.8were build-only on macOS.org.openapitools.codegen.csharpnetcore.*Test: 89 passed.- On a 337-model production spec, every model that compiles on master still compiles.
Related PR
#25260 by @Arthur031221 fixes #25154 with the same change this PR makes for that case: it removes the ^model.discriminator guard around the anyOf object merge. This PR includes its testAnyOfDiscriminatorWritesSelectedSubtype test, with Arthur as co-author, and the test passes here. The two PRs conflict in JsonConverter.mustache, so if #25260 lands first I'll rebase this one onto it.
Merge Danger
Door: two-way. Templates only; reverting restores the old output.
Blast Radius: generichost unions.
JSON output changes only for unions that wrote {} or {"ClassName": value} before. Output for object anyOf and discriminated oneOf is unchanged. A oneOf whose object members accept the same JSON still sets more than one member on read, as on master.
PR checklist
- Read the contribution guidelines.
- Run the following to build the project and update samples:
Commit all changed files../mvnw clean package || exit ./bin/generate-samples.sh ./bin/configs/*.yaml || exit ./bin/utils/export_docs_generators.sh || exit - If your PR is targeting a particular programming language, @mention the technical committee members, so they are more likely to review the pull request.
@devhl-labs @mandrean @shibayan @Blackclaws @lucamazzanti: review requested for the C# generichost converter change.
Summary by cubic
Fixes the C# generichost union converter so oneOf/anyOf members that aren't mergeable objects — enums, primitives, and containers — are read and written as values. Previously every member was treated as an object: string and enum members threw on read, array members didn't compile, and undiscriminated oneOf plus discriminated anyOf wrote {}. Object members are still merged, so unions with their own properties keep them.
Behavior
- Object tokens read members once as before; any other token deserializes into value members.
- Reading skips a duplicated last member without skipping the object loop, and discriminated unions fail fast on non-object tokens.
- Arrays and scalars are kept out of free-form object members;
anyOfthrows when no member matches. - A set value member serializes as itself; all other unions merge object members.
- Removed the broken write paths that emitted
{"ClassName": value}and skipped list properties.
Testing