From 214717478c05e7da6fba6bf78c8b7d1af3a70ea5 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Thu, 20 Aug 2026 16:05:02 +0200 Subject: [PATCH] fix nullable for openapi gen --- .../sample/Endpoints/MapUnionsEndpoints.cs | 3 + .../Extensions/JsonNodeSchemaExtensions.cs | 12 +- ...iDocument_documentName=unions.verified.txt | 108 ++++++++++++++++++ ...iDocument_documentName=unions.verified.txt | 105 +++++++++++++++++ ...iDocument_documentName=unions.verified.txt | 105 +++++++++++++++++ ...ifyOpenApiDocumentIsInvariant.verified.txt | 105 +++++++++++++++++ .../OpenApiSchemaService.UnionSchemas.cs | 94 +++++++++++++++ .../Shared/SharedTypes.Unions.cs | 18 +++ 8 files changed, 549 insertions(+), 1 deletion(-) diff --git a/src/OpenApi/sample/Endpoints/MapUnionsEndpoints.cs b/src/OpenApi/sample/Endpoints/MapUnionsEndpoints.cs index a7d473c56e52..d9f616047af0 100644 --- a/src/OpenApi/sample/Endpoints/MapUnionsEndpoints.cs +++ b/src/OpenApi/sample/Endpoints/MapUnionsEndpoints.cs @@ -44,6 +44,9 @@ public static IEndpointRouteBuilder MapUnionsEndpoints(this IEndpointRouteBuilde // used directly elsewhere registers as a separate component schema. unions.MapGet("/kitten-standalone", () => new Kitten("Whiskers", 9)); + // Container record whose property is a *nullable* union with a polymorphic case type. + unions.MapGet("/beasts", () => new BeastEnvelope(new OneOrManyBeast(new Dachshund("Chili")))); + return endpointRouteBuilder; } } diff --git a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs index f25fe33666ed..ca4d3abdbf87 100644 --- a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs +++ b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs @@ -476,7 +476,17 @@ internal static void ApplySchemaReferenceId(this JsonNode schema, JsonSchemaExpo { schema[OpenApiConstants.SchemaId] = schemaReferenceId; } - if (context.TypeInfo.Kind == JsonTypeInfoKind.Union) + + // C# union types are value types, so in case of Nullable JsonTypeInfoKind is None. + // We need to unpack it to properly detect the union: see https://github.com/dotnet/aspnetcore/issues/68653. + var unionTypeInfo = context.TypeInfo; + if (Nullable.GetUnderlyingType(unionTypeInfo.Type) is { } underlyingType + && unionTypeInfo.Options.TryGetTypeInfo(underlyingType, out var underlyingTypeInfo)) + { + unionTypeInfo = underlyingTypeInfo; + } + + if (unionTypeInfo.Kind == JsonTypeInfoKind.Union) { schema[OpenApiConstants.SchemaIsUnion] = true; } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=unions.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=unions.verified.txt index 7aac8ea450ee..68a83791f6e7 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=unions.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=unions.verified.txt @@ -202,10 +202,105 @@ } } } + }, + "/unions/beasts": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BeastEnvelope" + } + } + } + } + } + } } }, "components": { "schemas": { + "Beast": { + "required": [ + "type" + ], + "type": "object", + "anyOf": [ + { + "$ref": "#/components/schemas/BeastLion" + }, + { + "$ref": "#/components/schemas/BeastDachshund" + } + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "lion": "#/components/schemas/BeastLion", + "dachshund": "#/components/schemas/BeastDachshund" + } + } + }, + "BeastDachshund": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "type": { + "enum": [ + "dachshund" + ], + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "BeastEnvelope": { + "required": [ + "beasts" + ], + "type": "object", + "properties": { + "beasts": { + "oneOf": [ + { + "enum": [ + null + ], + "nullable": true + }, + { + "$ref": "#/components/schemas/OneOrManyBeast" + } + ] + } + } + }, + "BeastLion": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "type": { + "enum": [ + "lion" + ], + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "Clinic": { "required": [ "address", @@ -237,6 +332,19 @@ } } }, + "OneOrManyBeast": { + "anyOf": [ + { + "$ref": "#/components/schemas/Beast" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/Beast" + } + } + ] + }, "Puppy": { "required": [ "name", diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=unions.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=unions.verified.txt index 844a9ccdd0a1..02426ed79a00 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=unions.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=unions.verified.txt @@ -202,10 +202,102 @@ } } } + }, + "/unions/beasts": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BeastEnvelope" + } + } + } + } + } + } } }, "components": { "schemas": { + "Beast": { + "required": [ + "type" + ], + "type": "object", + "anyOf": [ + { + "$ref": "#/components/schemas/BeastLion" + }, + { + "$ref": "#/components/schemas/BeastDachshund" + } + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "lion": "#/components/schemas/BeastLion", + "dachshund": "#/components/schemas/BeastDachshund" + } + } + }, + "BeastDachshund": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "type": { + "enum": [ + "dachshund" + ], + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "BeastEnvelope": { + "required": [ + "beasts" + ], + "type": "object", + "properties": { + "beasts": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/OneOrManyBeast" + } + ] + } + } + }, + "BeastLion": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "type": { + "enum": [ + "lion" + ], + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "Clinic": { "required": [ "address", @@ -237,6 +329,19 @@ } } }, + "OneOrManyBeast": { + "anyOf": [ + { + "$ref": "#/components/schemas/Beast" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/Beast" + } + } + ] + }, "Puppy": { "required": [ "name", diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=unions.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=unions.verified.txt index 4feefa691b8b..de98ef7c6c4e 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=unions.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=unions.verified.txt @@ -202,10 +202,102 @@ } } } + }, + "/unions/beasts": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BeastEnvelope" + } + } + } + } + } + } } }, "components": { "schemas": { + "Beast": { + "required": [ + "type" + ], + "type": "object", + "anyOf": [ + { + "$ref": "#/components/schemas/BeastLion" + }, + { + "$ref": "#/components/schemas/BeastDachshund" + } + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "lion": "#/components/schemas/BeastLion", + "dachshund": "#/components/schemas/BeastDachshund" + } + } + }, + "BeastDachshund": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "type": { + "enum": [ + "dachshund" + ], + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "BeastEnvelope": { + "required": [ + "beasts" + ], + "type": "object", + "properties": { + "beasts": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/OneOrManyBeast" + } + ] + } + } + }, + "BeastLion": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "type": { + "enum": [ + "lion" + ], + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "Clinic": { "required": [ "address", @@ -237,6 +329,19 @@ } } }, + "OneOrManyBeast": { + "anyOf": [ + { + "$ref": "#/components/schemas/Beast" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/Beast" + } + } + ] + }, "Puppy": { "required": [ "name", diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt index 5ba2af19482a..f9e9eada5d50 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt @@ -2297,6 +2297,25 @@ } } }, + "/unions/beasts": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BeastEnvelope" + } + } + } + } + } + } + }, "/obsolete/deprecated": { "post": { "tags": [ @@ -2783,6 +2802,79 @@ } } }, + "Beast": { + "required": [ + "type" + ], + "type": "object", + "anyOf": [ + { + "$ref": "#/components/schemas/BeastLion" + }, + { + "$ref": "#/components/schemas/BeastDachshund" + } + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "lion": "#/components/schemas/BeastLion", + "dachshund": "#/components/schemas/BeastDachshund" + } + } + }, + "BeastDachshund": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "type": { + "enum": [ + "dachshund" + ], + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "BeastEnvelope": { + "required": [ + "beasts" + ], + "type": "object", + "properties": { + "beasts": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/OneOrManyBeast" + } + ] + } + } + }, + "BeastLion": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "type": { + "enum": [ + "lion" + ], + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "BoardItem": { "required": [ "name" @@ -3386,6 +3478,19 @@ }, "deprecated": true }, + "OneOrManyBeast": { + "anyOf": [ + { + "$ref": "#/components/schemas/Beast" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/Beast" + } + } + ] + }, "ParentObject": { "type": "object", "properties": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.UnionSchemas.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.UnionSchemas.cs index 330278fca505..6976c505dbeb 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.UnionSchemas.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.UnionSchemas.cs @@ -146,4 +146,98 @@ await VerifyOpenApiDocument(builder, document => Assert.Equal(2, unionComponent.AnyOf.Count); }); } + + [Fact] + public async Task GetOpenApiResponse_UnionWithPolymorphicCase_ReturnedDirectly_BranchesRefPolymorphicBase() + { + var builder = CreateBuilder(); + + builder.MapGet("/api/beasts", () => new OneOrManyBeast(new Dachshund("Chili"))); + + await VerifyOpenApiDocument(builder, document => + { + Assert.True(document.Components.Schemas.TryGetValue(nameof(OneOrManyBeast), out var unionComponent)); + Assert.NotNull(unionComponent.AnyOf); + Assert.Equal(2, unionComponent.AnyOf.Count); + + var objectBranch = Assert.IsType(unionComponent.AnyOf[0]); + Assert.Equal(nameof(Beast), objectBranch.Reference.Id); + + var arrayItems = Assert.IsType(unionComponent.AnyOf[1].Items); + Assert.Equal(nameof(Beast), arrayItems.Reference.Id); + + Assert.DoesNotContain(nameof(OneOrManyBeast) + nameof(Beast), document.Components.Schemas.Keys); + }); + } + + [Fact] + public async Task GetOpenApiResponse_NestedUnionWithPolymorphicCase_DoesNotDuplicatePolymorphicComponent() + { + var builder = CreateBuilder(); + + builder.MapGet("/api/beasts", () => new BeastEnvelope(new OneOrManyBeast(new Dachshund("Chili")))); + + await VerifyOpenApiDocument(builder, document => + { + Assert.True(document.Components.Schemas.TryGetValue(nameof(OneOrManyBeast), out var unionComponent)); + Assert.NotNull(unionComponent.AnyOf); + Assert.Equal(2, unionComponent.AnyOf.Count); + + // Both branches must reference the polymorphic base component `Beast`; the object + // case must not be lifted into a duplicate `OneOrManyBeastBeast` component. + var objectBranch = Assert.IsType(unionComponent.AnyOf[0]); + Assert.Equal(nameof(Beast), objectBranch.Reference.Id); + + var arrayItems = Assert.IsType(unionComponent.AnyOf[1].Items); + Assert.Equal(nameof(Beast), arrayItems.Reference.Id); + + Assert.DoesNotContain(nameof(OneOrManyBeast) + nameof(Beast), document.Components.Schemas.Keys); + }); + } + + [Fact] + public async Task GetOpenApiResponse_NestedNonNullableUnionWithPolymorphicCase_DoesNotDuplicatePolymorphicComponent() + { + var builder = CreateBuilder(); + + builder.MapGet("/api/beasts", () => new BeastContainer(new OneOrManyBeast(new Dachshund("Chili")))); + + await VerifyOpenApiDocument(builder, document => + { + Assert.True(document.Components.Schemas.TryGetValue(nameof(OneOrManyBeast), out var unionComponent)); + Assert.NotNull(unionComponent.AnyOf); + Assert.Equal(2, unionComponent.AnyOf.Count); + + var objectBranch = Assert.IsType(unionComponent.AnyOf[0]); + Assert.Equal(nameof(Beast), objectBranch.Reference.Id); + + var arrayItems = Assert.IsType(unionComponent.AnyOf[1].Items); + Assert.Equal(nameof(Beast), arrayItems.Reference.Id); + + Assert.DoesNotContain(nameof(OneOrManyBeast) + nameof(Beast), document.Components.Schemas.Keys); + }); + } + + [Fact] + public async Task GetOpenApiResponse_TopLevelNullableUnionWithPolymorphicCase_DoesNotDuplicatePolymorphicComponent() + { + var builder = CreateBuilder(); + + builder.MapGet("/api/beasts", OneOrManyBeast? () => new OneOrManyBeast(new Dachshund("Chili"))); + + await VerifyOpenApiDocument(builder, document => + { + Assert.True(document.Components.Schemas.TryGetValue(nameof(OneOrManyBeast), out var unionComponent)); + Assert.NotNull(unionComponent.AnyOf); + Assert.Equal(2, unionComponent.AnyOf.Count); + + var objectBranch = Assert.IsType(unionComponent.AnyOf[0]); + Assert.Equal(nameof(Beast), objectBranch.Reference.Id); + + var arrayItems = Assert.IsType(unionComponent.AnyOf[1].Items); + Assert.Equal(nameof(Beast), arrayItems.Reference.Id); + + Assert.DoesNotContain(nameof(OneOrManyBeast) + nameof(Beast), document.Components.Schemas.Keys); + }); + } } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Shared/SharedTypes.Unions.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Shared/SharedTypes.Unions.cs index 0cb7de1a45be..830d77b10b05 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Shared/SharedTypes.Unions.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Shared/SharedTypes.Unions.cs @@ -6,6 +6,8 @@ // primitive-paired and object-cased unions, plus a container record that references a union // to validate component schema reuse. +using System.Text.Json.Serialization; + internal record Kitten(string Name, int Lives); internal record Puppy(string Name, string Breed); @@ -15,3 +17,19 @@ internal record Puppy(string Name, string Breed); internal union UnionIntString(int, string); internal record Clinic(string Address, UnionPet Patient); + +// Union with a polymorphic case type plus a collection of the same polymorphic type. +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(Lion), "lion")] +[JsonDerivedType(typeof(Dachshund), "dachshund")] +internal abstract record Beast(string Name); + +internal sealed record Lion(string Name) : Beast(Name); + +internal sealed record Dachshund(string Name) : Beast(Name); + +internal union OneOrManyBeast(Beast, IReadOnlyList); + +internal sealed record BeastEnvelope(OneOrManyBeast? Beasts); + +internal sealed record BeastContainer(OneOrManyBeast Beasts);