Skip to content

OpenAPI: property-level annotations are emitted inside the oneOf branch for nullable componentized properties #69727

Description

@sander1095

Is there an existing issue for this?

  • I have searched the existing issues

Describe the bug

When a property is both nullable and of a componentized type (a class/record lifted into #/components/schemas), Microsoft.AspNetCore.OpenApi wraps it in a oneOf to express nullability:

"optionalNested": {
  "oneOf": [
    { "type": "null" },
    { "$ref": "#/components/schemas/NestedModel" }
  ]
}

Property-level annotations — description and deprecated — are then attached to the $ref inside the oneOf, rather than to the property schema that owns the oneOf.

This is semantically wrong. oneOf describes which values are permitted; description and deprecated describe the property slot. Placing them in a branch means they apply only when that branch matches, and most tooling (client generators, UI renderers) reads annotations from the property's top level and therefore misses them entirely.

Non-nullable properties of the same type are annotated correctly, so the output is inconsistent within a single schema.

Expected Behavior

The annotations belong on the wrapper:

"optionalNested": {
  "description": "The nested model, when present.",
  "deprecated": true,
  "oneOf": [
    { "type": "null" },
    { "$ref": "#/components/schemas/NestedModel" }
  ]
}

Steps To Reproduce

#:sdk Microsoft.NET.Sdk.Web

#:package Microsoft.AspNetCore.OpenApi@10.0.12

using System.ComponentModel;
using System.Text.Json.Serialization;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();

// File-based apps default to PublishAot=true, which disables System.Text.Json
// reflection, so the source-generated context has to be registered explicitly.
builder.Services.ConfigureHttpJsonOptions(options =>
    options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));

var app = builder.Build();

app.MapOpenApi();

app.MapPost("/example", (ExampleRequest request) => TypedResults.Ok(request));

app.Run();

public sealed class ExampleRequest
{
    /// <summary>The nested model, when present.</summary>
    [Description("The nested model, when present.")]
    [Obsolete]
    public NestedModel? OptionalNested { get; set; }

    /// <summary>Always present.</summary>
    [Description("Always present.")]
    [Obsolete]
    public NestedModel RequiredNested { get; set; } = new() { Name = "x" };
}

public sealed class NestedModel
{
    public required string Name { get; set; }
}

[JsonSerializable(typeof(ExampleRequest))]
[JsonSerializable(typeof(NestedModel))]
public partial class AppJsonContext : JsonSerializerContext
{
}

Actual output (OpenAPI 3.1):

"ExampleRequest": {
  "type": "object",
  "properties": {
    "optionalNested": {
      "oneOf": [
        { "type": "null" },
        {
          "description": "The nested model, when present.",
          "deprecated": true,
          "$ref": "#/components/schemas/NestedModel"
        }
      ]
    },
    "requiredNested": {
      "description": "Always present.",
      "deprecated": true,
      "$ref": "#/components/schemas/NestedModel"
    }
  }
}

Note the asymmetry: requiredNested carries its annotations at the property level, optionalNested does not.

Exceptions (if any)

None — the document generates successfully, it is just incorrect.

.NET Version

11.0.100-rc.1.26420.103

Anything else?

Root cause

Property-level annotations are carried forward from the schema-generation stage as x-ref-* metadata keys (OpenApiConstants), because at generation time the property's schema node has not yet been split into a component plus a $ref. They are unpacked onto the OpenApiSchemaReference in OpenApiDocumentExtensions.AddOpenApiSchemaByReference:

https://github.com/dotnet/aspnetcore/blob/main/src/OpenApi/src/Extensions/OpenApiDocumentExtensions.cs#L27-L45

The nullable wrapper is applied afterwards, in OpenApiSchemaService.ResolveReferenceForSchema:

https://github.com/dotnet/aspnetcore/blob/main/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs#L380-L390

var resolvedProperty = ResolveReferenceForSchema(document, propertyValue, rootSchemaId);
if (propertyValue is OpenApiSchema targetSchema &&
    targetSchema.Metadata?.TryGetValue(OpenApiConstants.NullableProperty, out var isNullableProperty) == true &&
    isNullableProperty is true)
{
    schema.Properties[key] = resolvedProperty.CreateOneOfNullableWrapper();
}

Because resolvedProperty already carries the annotations at that point, CreateOneOfNullableWrapper() encloses them in the oneOf branch instead of lifting them out.

Proposed fix

In ResolveReferenceForSchema, before (or as part of) CreateOneOfNullableWrapper(), move the property-level annotations off the inner reference and onto the newly created wrapper schema.

Annotations affected today:

  • description (x-ref-description)
  • deprecated (x-ref-deprecated)

default (x-ref-default) should be reviewed as part of the same change.

Related

Property-level description / deprecated support was added in #63494. This is a gap in that implementation for the nullable + componentized case.

#58617 established the same principle from the other direction: schema-level facts belong on the component, use-site facts do not.

readOnly / writeOnly support is being added for #65403. Those flags are property-level in exactly the same way and will land in the same oneOf branch, according to how I plan to fix that. That work deliberately matches the existing description/deprecated behaviour rather than diverging, so that all of these can be corrected together here.

Environment

  • Reproduced against main with a locally built Microsoft.AspNetCore.OpenApi.

Activity

  1. added
    area-minimalIncludes minimal APIs, endpoint filters, parameter binding, request delegate generator etc
    on Oct 8, 2026
  2. github-actions commented on Oct 8, 2026

    @github-actions
    Contributor

    Triage Summary

    Area: area-minimal (Microsoft.AspNetCore.OpenApi schema generation)
    Type: Bug (property-level description/deprecated land inside the oneOf branch for nullable componentized properties)

    Potential Duplicates

    • None found

    Notes

    Generated by Issue Triage Agent for dotnet/aspnetcore for #69727 · copilot · auto · 26.8 AIC · ⌖ 0.998 AIC · ⊞ 30.4K · ◷

  3. sander1095 commented on Oct 8, 2026

    @sander1095
    ContributorAuthor

    As the reporter of the bug I'd love to create a PR, just like I did last year for several OpenAPI related issues and features! Just say the word !

  4. sander1095 commented on Oct 8, 2026

    @sander1095
    ContributorAuthor

    This seems like a real issue. In .NET 11, the deprecated keyword will be enabled by default, and if someone has marked a nullable property as obsolete, the OpenAPI document will thus be incorrect, and so things like API Client generation might/will break! I think we should fix this ASAP!

    I've tested the result of the OpenAPI document with several API client generators, and all of them omit the deprecated keyword in the generated client.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-minimalIncludes minimal APIs, endpoint filters, parameter binding, request delegate generator etcfeature-openapi

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions