OpenAPITools / OpenAPITools/openapi-generator

BUG][csharp][generichost] Nullable value-type properties dropped to non-nullable when inherited via `allOf`, causing JsonException on null

Open
#23,720 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Issue: Bug
Dominant language
Java
Stars
26.8k
Forks
7.7k
PR merge metrics
PR metrics pending

Description

Bug Report Checklist
  • Have you provided a full/minimal spec to reproduce the issue?
  • Have you validated the input using an OpenAPI validator?
  • Have you tested with the latest master to confirm the issue still exists?
  • Have you searched for related issues/PRs? (See "Related issues" below.)
  • What's the actual output vs expected output?
  • [Optional] Sponsorship to speed up the bug fix or feature request
Description

When a schema uses allOf to inherit a base that declares a nullable value-type property (type: ['string', 'null'] with format: date-time, etc.), the generated JsonConverter for the derived class deserializes the property as non-nullable T (e.g. JsonSerializer.Deserialize<DateTimeOffset>) and emits an ArgumentNullException throw-on-null check. The base class generates the same property correctly (Deserialize<DateTimeOffset?>, no throw). At runtime, any payload with JSON null for that property fails:

System.Text.Json.JsonException: The JSON value could not be converted to System.DateTimeOffset.
 ---> System.InvalidOperationException: Cannot get the value of a token type 'Null' as a string.
   at System.Text.Json.Utf8JsonReader.GetDateTimeOffset()
   at <DerivedDto>JsonConverter.Read(...)

The wrapper type Option<T?> is generated correctly in the derived class — only the inner Deserialize<T> call type and the post-deserialization null check are wrong. The asymmetry shows that vendorExtensions.x-is-value-type survives the allOf flattening (so the wrapper gets T?), while isNullable does not (so the deserialize call gets T and the throw block fires). This is the same root mismatch identified in #23530, but here it produces a runtime crash, not just generated-code inconsistency.

openapi-generator version

Reproduced on latest master (7.x line). Did not occur on the legacy restsharp library — first observable after switching the C# library to generichost.

OpenAPI declaration
openapi: 3.1.0
info: { title: repro, version: 1.0.0 }
paths: {}
components:
  schemas:
    Base:
      type: object
      properties:
        requiredAt:
          type: string
          format: date-time
        optionalAt:
          type: ['string', 'null']
          format: date-time
      required: [requiredAt]
    Derived:
      allOf:
        - $ref: '#/components/schemas/Base'
        - type: object
          properties:
            extra:
              type: string
Generation Details
openapi-generator-cli generate -i spec.yaml -g csharp \
  --additional-properties=library=generichost,useDateTimeOffset=true,targetFramework=net10.0
Steps to reproduce
  1. Generate from the spec above.
  2. Diff Model/BaseJsonConverter.Read against Model/DerivedJsonConverter.Read for the optionalAt case. The base emits Deserialize<DateTimeOffset?> and no throw block; the derived emits Deserialize<DateTimeOffset> and an ArgumentNullException throw.
  3. Deserialize {"requiredAt":"2026-04-14T16:00:44Z","optionalAt":null,"extra":"x"} into Derived — the call throws.
Expected vs actual
Base (correct) Derived via allOf (buggy)
Wrapper Option<DateTimeOffset?> Option<DateTimeOffset?>
Deserialize call Deserialize<DateTimeOffset?> Deserialize<DateTimeOffset>
Throw-on-null block absent present

Expected: derived matches base.

Related issues / PRs
  • #23530 — same template-signal mismatch (OptionProperty.mustache uses x-is-value-type, JsonConverter.mustache uses isNullable), identified without the allOf angle. Most directly related.
  • #22474 — same throw-on-null block fires for non-required nullable fields.
  • #23190 (PR #23189) — same throw block, reference-type non-required case.
Suggested fix

Two layers:

  1. Java side (root cause). In the allOf-flattening logic of DefaultCodegen / CSharpClientCodegen, when a base schema's properties are merged onto a derived schema, CodegenProperty.isNullable is overwritten to false for value-type properties even when the base spec marks them nullable. The fix is to preserve isNullable for inherited properties.

  2. Mustache side (workaround that also makes the templates self-consistent). In csharp/libraries/generichost/JsonConverter.mustache:

  • Lines 220–225 (isDate / isDateTime deserialize): always emit the ? for value types — the wrapper is already Option<T?> and reading as T? from non-null JSON is a no-op for value types. This matches the contract that OptionProperty.mustache already enforces.
  • Lines 277–283 (throw-on-null block): emit the throw for (reference type) OR (value type AND required); skip only for non-required value types. Concretely:
    {{#allVars}}                                                                                                                                                                                                                 
    {{^isNullable}}                                                                                                                                                                                                              
    {{#vendorExtensions.x-is-reference-type}}                                                                                                                                                                                    
    if ({{name}}.IsSet && {{name}}.Value == null) throw new ArgumentNullException(...);                                                                                                                                          
    {{/vendorExtensions.x-is-reference-type}}                                                                                                                                                                                    
    {{#vendorExtensions.x-is-value-type}}                                                                                                                                                                                        
    {{#required}}                                                                                                                                                                                                                
    if ({{name}}.IsSet && {{name}}.Value == null) throw new ArgumentNullException(...);                                                                                                                                          
    {{/required}}                                                                                                                                                                                                                
    {{/vendorExtensions.x-is-value-type}}                                                                                                                                                                                        
    {{/isNullable}}                                                                                                                                                                                                              
    {{/allVars}}
    
    Rationale: required survives allOf flattening, so it's a reliable proxy for "callers genuinely expect a value here." Required value types keep the clear ArgumentNullException; the only surface that loses strictness is non-required non-nullable value types, where the template can't tell apart "spec-nullable but reset by allOf" from "genuinely non-nullable optional." That's a narrow tolerance shift, not a crash.

PR welcome — happy to put one up.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by tracing allOf property merging in DefaultCodegen and CSharpClientCodegen, then inspect csharp/libraries/generichost/JsonConverter.mustache and the related OptionProperty.mustache behavior. Run the supplied generation command with the minimal spec and compare BaseJsonConverter.Read with DerivedJsonConverter.Read; done means the derived converter preserves nullable deserialization and avoids the erroneous throw for optional nullable values.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp, java
Domain
backend-api-design, tooling
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.