Is there an existing issue for this?
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.
Is there an existing issue for this?
Describe the bug
When a property is both nullable and of a componentized type (a class/record lifted into
#/components/schemas),Microsoft.AspNetCore.OpenApiwraps it in aoneOfto express nullability:Property-level annotations —
descriptionanddeprecated— are then attached to the$refinside theoneOf, rather than to the property schema that owns theoneOf.This is semantically wrong.
oneOfdescribes which values are permitted;descriptionanddeprecateddescribe 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:
Steps To Reproduce
Actual output (OpenAPI 3.1):
Note the asymmetry:
requiredNestedcarries its annotations at the property level,optionalNesteddoes 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 theOpenApiSchemaReferenceinOpenApiDocumentExtensions.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
Because
resolvedPropertyalready carries the annotations at that point,CreateOneOfNullableWrapper()encloses them in theoneOfbranch 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/deprecatedsupport 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/writeOnlysupport is being added for #65403. Those flags are property-level in exactly the same way and will land in the sameoneOfbranch, according to how I plan to fix that. That work deliberately matches the existingdescription/deprecatedbehaviour rather than diverging, so that all of these can be corrected together here.Environment
mainwith a locally builtMicrosoft.AspNetCore.OpenApi.