[csharp][generichost] Skip unset optional string enums and write null enums

Merged
ND
7h ago
+732-304

Fixes #25267

WriteProperties writes inline string enums without the Option.IsSet check that other optional properties get, and it reads .Value on nullable enums without checking for null first. Serializing a model throws InvalidOperationException: Nullable object must have a value when:

  • an optional enum is not set (#25267)
  • an optional nullable enum is not set, or is set to null
  • a required nullable enum is null

Since #23912 the reader turns a JSON null into a set Option holding null, so the last two cases also break round-tripping a response such as {"kind": null}.

The fix wraps optional enums in if (...Option.IsSet), and for nullable enums writes null when the value is null instead of calling .Value. Required non-nullable enums are unchanged. Numeric enums and enums that reference a schema already go through other branches that handle this, so they are unchanged too.

Spec

openapi: 3.0.3
info: { title: repro, version: 1.0.0 }
paths: {}
components:
  schemas:
    Pet:
      type: object
      required: [name, kind]
      properties:
        name: { type: string }
        status: { type: string, enum: [available, sold] }
        mode: { type: string, nullable: true, enum: [auto, manual] }
        kind: { type: string, nullable: true, enum: [cat, dog] }

Before

var statusRawValue = Pet.StatusEnumToJsonValue(pet.StatusOption.Value!.Value);   // throws when unset
writer.WriteString("status", statusRawValue);
var modeRawValue = Pet.ModeEnumToJsonValue(pet.ModeOption.Value!.Value);         // throws when unset or null
if (modeRawValue != null)
    writer.WriteString("mode", modeRawValue);
else
    writer.WriteNull("mode");

var kindRawValue = Pet.KindEnumToJsonValue(pet.Kind!.Value);                       // throws when null

After

if (pet.StatusOption.IsSet)
{
    var statusRawValue = Pet.StatusEnumToJsonValue(pet.StatusOption.Value!.Value);
    writer.WriteString("status", statusRawValue);
}
if (pet.ModeOption.IsSet)
{
    if (pet.ModeOption.Value == null)
        writer.WriteNull("mode");
    else
    {
        var modeRawValue = Pet.ModeEnumToJsonValue(pet.ModeOption.Value!.Value);
        writer.WriteString("mode", modeRawValue);
    }
}

if (pet.Kind == null)
    writer.WriteNull("kind");
else
{
    var kindRawValue = Pet.KindEnumToJsonValue(pet.Kind!.Value);
    writer.WriteString("kind", kindRawValue);
}

Deserializing and then serializing each of these, with nullableReferenceTypes on and off:

InputBeforeAfter
{"name":"Rex","kind":"cat"}throws{"name":"Rex","kind":"cat"}
{"name":"Rex","kind":"cat","status":"sold","mode":null}throws{"name":"Rex","status":"sold","mode":null,"kind":"cat"}
{"name":"Rex","kind":null,"status":"sold","mode":"auto"}throws{"name":"Rex","status":"sold","mode":"auto","kind":null}
all fields setsame outputsame output

Testing

  • ./mvnw -DskipTests -pl modules/openapi-generator-cli -am package
  • ./bin/generate-samples.sh bin/configs/csharp-generichost-*.yaml (50 configs): 91 model files change (EnumArrays, EnumTest, Order, Pet, RequiredClass, Zebra, ZeroBasedEnumClass), +663/−299 lines. Regenerating these configs on master first produced no changes.
  • Added CSharpClientCodegenTest#testGenericHostWritesUnsetAndNullInnerStringEnums. It fails without the template change and passes with it. CSharpClientCodegenTest: 24 tests, 0 failures.
  • Generated a net8.0 client from the spec above and ran the round trips in the table with the .NET 9 SDK.

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
    
    (For Windows users, please run the script in WSL)
    Commit all changed files.
    This is important, as CI jobs will verify all generator outputs of your HEAD commit as it would merge with master.
    These must match the expectations made by your contribution.
    You may regenerate an individual generator by passing the relevant config(s) as an argument to the script, for example ./bin/generate-samples.sh bin/configs/java*.
    IMPORTANT: Do NOT purge/delete any folders/files (e.g. tests) when regenerating the samples as manually written tests may be removed.
    (Ran the build and regenerated the csharp-generichost-* configs, the only ones that use this template; not all configs.)
  • If your PR is targeting a particular programming language, @mention the technical committee members, so they are more likely to review the pull request.

@mandrean @shibayan @Blackclaws @lucamazzanti @devhl-labs


Summary by cubic

Fixes C# generichost serialization so models with inline string enums no longer throw Nullable object must have a value.

WriteProperties in the JsonConverter.mustache template was missing the Option.IsSet check that other optional properties get, and it read .Value on nullable enums without checking for null first. Optional string enums that are unset are now skipped, and null nullable enums are written as JSON null. Previously, serializing threw when an optional enum was unset, an optional nullable enum was unset or explicitly null, or a required nullable enum was null.

Bug Fixes

  • Wraps optional string enum serialization in an Option.IsSet check.
  • Writes null for null nullable enums instead of reading .Value.
  • Adds a codegen test that asserts set enums are serialized as well, and regenerates the csharp-generichost-* samples.