[csharp][generichost] Serialize union members that are not merged objects

SP
1d ago
+14277-3006

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:

IssueShapeMaster
#23261anyOf/oneOf with a string or enum memberRead throws on every string
#23529anyOf: [Foo, Foo[]]Doesn't compile
#23693oneOf of objects, no discriminatorWrite emits {}
#25154anyOf with a discriminatorWrite 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-object anyOf and 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 for latest, net8, net9, net10 and standard2.0. net4.7/net4.8 were 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:
    ./mvnw clean package || exit
    ./bin/generate-samples.sh ./bin/configs/*.yaml || exit
    ./bin/utils/export_docs_generators.sh || exit
    
    Commit all changed files.
  • 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; anyOf throws 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

  • New UnionMembers sample with round-trip tests covering #23261, #23529, #23693, and #25154.
  • All C# generichost samples build, and tests pass on net8, net9, net10, and standard2.0.