From 1684f4d79f40c5c69e586250946ff7127195e0b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?So=C5=88a=20Neme=C4=8Dkayov=C3=A1?= <109719150+snemeckayova@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:12:42 +0200 Subject: [PATCH 1/3] Support nullable array element types in parameter schemas --- .../src/Extensions/OpenApiSchemaExtensions.cs | 17 +++++++++++++++++ src/OpenApi/src/Extensions/TypeExtensions.cs | 13 +++++++++++++ .../src/Services/OpenApiDocumentService.cs | 15 ++++++++++++++- .../OpenApiSchemaService.ParameterSchemas.cs | 5 +---- 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/OpenApi/src/Extensions/OpenApiSchemaExtensions.cs b/src/OpenApi/src/Extensions/OpenApiSchemaExtensions.cs index aa900d04a3fa..22abb1d0e5e8 100644 --- a/src/OpenApi/src/Extensions/OpenApiSchemaExtensions.cs +++ b/src/OpenApi/src/Extensions/OpenApiSchemaExtensions.cs @@ -21,6 +21,23 @@ public static IOpenApiSchema CreateOneOfNullableWrapper(this IOpenApiSchema orig }; } + public static void MakeArrayItemsNullable(this IOpenApiSchema schema) + { + if (schema is not OpenApiSchema { Items: { } items } arraySchema) + { + return; + } + + if (items is OpenApiSchema { Type: { } itemType } inlineItemSchema) + { + inlineItemSchema.Type = itemType | JsonSchemaType.Null; + } + else + { + arraySchema.Items = items.CreateOneOfNullableWrapper(); + } + } + public static bool IsComponentizedSchema(this OpenApiSchema schema) => schema.IsComponentizedSchema(out _); diff --git a/src/OpenApi/src/Extensions/TypeExtensions.cs b/src/OpenApi/src/Extensions/TypeExtensions.cs index 19351511f019..394569f3788a 100644 --- a/src/OpenApi/src/Extensions/TypeExtensions.cs +++ b/src/OpenApi/src/Extensions/TypeExtensions.cs @@ -90,6 +90,19 @@ public static bool ShouldApplyNullableRequestSchema(this ApiParameterDescription return nullabilityInfo.WriteState == NullabilityState.Nullable; } + public static bool ShouldApplyNullableArrayElementSchema(this ApiParameterDescription apiParameterDescription) + { + if (apiParameterDescription.Type is not { IsArray: true } || + apiParameterDescription.ParameterDescriptor is not IParameterInfoParameterDescriptor { ParameterInfo: { } parameterInfo }) + { + return false; + } + + var nullabilityInfoContext = new NullabilityInfoContext(); + var nullabilityInfo = nullabilityInfoContext.Create(parameterInfo); + return nullabilityInfo.ElementType?.WriteState == NullabilityState.Nullable; + } + public static bool ShouldApplyNullablePropertySchema(this JsonPropertyInfo jsonPropertyInfo) { if (jsonPropertyInfo.AttributeProvider is not PropertyInfo propertyInfo) diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index 84ea82b84ec9..23a90b2a1f88 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -588,6 +588,19 @@ private static bool IsServerSentEventsContentType(string contentType) continue; } + var parameterSchema = await _componentService.GetOrCreateSchemaAsync( + document, + GetTargetType(description, parameter), + scopedServiceProvider, + schemaTransformers, + parameter, + cancellationToken: cancellationToken); + + if (parameter.ShouldApplyNullableArrayElementSchema()) + { + parameterSchema.MakeArrayItemsNullable(); + } + var openApiParameter = new OpenApiParameter { Name = parameter.Name, @@ -599,7 +612,7 @@ private static bool IsServerSentEventsContentType(string contentType) _ => ParameterLocation.Query }, Required = IsRequired(parameter), - Schema = await _componentService.GetOrCreateSchemaAsync(document, GetTargetType(description, parameter), scopedServiceProvider, schemaTransformers, parameter, cancellationToken: cancellationToken), + Schema = parameterSchema, Description = GetParameterDescriptionFromAttribute(parameter) }; diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ParameterSchemas.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ParameterSchemas.cs index c94ba08465a0..559759290d8b 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ParameterSchemas.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ParameterSchemas.cs @@ -459,10 +459,7 @@ await VerifyOpenApiDocument(builder, document => [(Guid[] id) => { }, JsonSchemaType.String, false], [(Guid?[] id) => { }, JsonSchemaType.String, true], [(string[] id) => { }, JsonSchemaType.String, false], - // Due to runtime restrictions, we can't resolve nullability - // info for reference types as element types so this will still - // encode as non-nullable. - [(string?[] id) => { }, JsonSchemaType.String, false], + [(string?[] id) => { }, JsonSchemaType.String, true], [(DateTime[] id) => { }, JsonSchemaType.String, false], [(DateTime?[] id) => { }, JsonSchemaType.String, true], [(DateTimeOffset[] id) => { }, JsonSchemaType.String, false], From d30c60f4d682e7d9c72d71b3e3dbf06b0ad41b4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?So=C5=88a=20Neme=C4=8Dkayov=C3=A1?= <109719150+snemeckayova@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:34:17 +0200 Subject: [PATCH 2/3] Add integration tests --- .../sample/Endpoints/MapSchemasEndpoints.cs | 11 ++++ ...t_documentName=schemas-by-ref.verified.txt | 62 +++++++++++++++++++ ...t_documentName=schemas-by-ref.verified.txt | 60 ++++++++++++++++++ ...t_documentName=schemas-by-ref.verified.txt | 60 ++++++++++++++++++ ...ifyOpenApiDocumentIsInvariant.verified.txt | 60 ++++++++++++++++++ 5 files changed, 253 insertions(+) diff --git a/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs b/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs index e59a2896e741..0786ee08c952 100644 --- a/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs +++ b/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs @@ -67,6 +67,17 @@ public static IEndpointRouteBuilder MapSchemasEndpoints(this IEndpointRouteBuild // Additional edge cases for nullable testing schemas.MapPost("/nullable-array-elements", (NullableArrayModel model) => Results.Ok(model)); + + // Ensures that applying nullability to the elements of one array parameter does not + // affect the shared componentized schema referenced by the other array parameter. + schemas.MapGet("/nullable-and-non-nullable-array-elements", (TestEnum?[] nullableValues, TestEnum[] values) => { }); + // Ensures the same componentized element type is reported correctly in a response. + schemas.MapGet("/complex-nullable-hierarchy", () => TypedResults.Ok(new ComplexHierarchyModel + { + Id = "id", + RequiredNested = new NestedModel { Name = "nested" } + })); + schemas.MapGet("/optional-with-default", () => TypedResults.Ok(new ModelWithDefaults())); schemas.MapGet("/nullable-enum-response", () => TypedResults.Ok(new EnumNullableModel { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt index a2cc35d399ed..f8d06edde4f5 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt @@ -756,6 +756,23 @@ "description": "OK" } } + }, + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ComplexHierarchyModel" + } + } + } + } + } } }, "/schemas-by-ref/nullable-array-elements": { @@ -780,6 +797,51 @@ } } }, + "/schemas-by-ref/nullable-and-non-nullable-array-elements": { + "get": { + "tags": [ + "Sample" + ], + "parameters": [ + { + "name": "nullableValues", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "oneOf": [ + { + "enum": [ + null + ] + }, + { + "$ref": "#/components/schemas/TestEnum" + } + ] + } + } + }, + { + "name": "values", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TestEnum" + } + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/schemas-by-ref/optional-with-default": { "get": { "tags": [ diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt index 9968124b8c44..a3b8aaea88b4 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt @@ -752,6 +752,23 @@ "description": "OK" } } + }, + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ComplexHierarchyModel" + } + } + } + } + } } }, "/schemas-by-ref/nullable-array-elements": { @@ -776,6 +793,49 @@ } } }, + "/schemas-by-ref/nullable-and-non-nullable-array-elements": { + "get": { + "tags": [ + "Sample" + ], + "parameters": [ + { + "name": "nullableValues", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/TestEnum" + } + ] + } + } + }, + { + "name": "values", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TestEnum" + } + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/schemas-by-ref/optional-with-default": { "get": { "tags": [ diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt index 7101b0f7e1cc..3a38ec720e67 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt @@ -752,6 +752,23 @@ "description": "OK" } } + }, + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ComplexHierarchyModel" + } + } + } + } + } } }, "/schemas-by-ref/nullable-array-elements": { @@ -776,6 +793,49 @@ } } }, + "/schemas-by-ref/nullable-and-non-nullable-array-elements": { + "get": { + "tags": [ + "Sample" + ], + "parameters": [ + { + "name": "nullableValues", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/TestEnum" + } + ] + } + } + }, + { + "name": "values", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TestEnum" + } + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/schemas-by-ref/optional-with-default": { "get": { "tags": [ 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 7ecc0814fb8d..ef886adffd88 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 @@ -1747,6 +1747,23 @@ "description": "OK" } } + }, + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ComplexHierarchyModel" + } + } + } + } + } } }, "/schemas-by-ref/nullable-array-elements": { @@ -1771,6 +1788,49 @@ } } }, + "/schemas-by-ref/nullable-and-non-nullable-array-elements": { + "get": { + "tags": [ + "Sample" + ], + "parameters": [ + { + "name": "nullableValues", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/TestEnum" + } + ] + } + } + }, + { + "name": "values", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TestEnum" + } + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/schemas-by-ref/optional-with-default": { "get": { "tags": [ From acc114fa3e930f3cf7cc9b67bb0e87e5cab856f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?So=C5=88a=20Neme=C4=8Dkayov=C3=A1?= <109719150+snemeckayova@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:45:17 +0200 Subject: [PATCH 3/3] Additional integration test --- .../sample/Endpoints/MapSchemasEndpoints.cs | 2 ++ ...t_documentName=schemas-by-ref.verified.txt | 22 +++++++++++++++++++ ...t_documentName=schemas-by-ref.verified.txt | 22 +++++++++++++++++++ ...t_documentName=schemas-by-ref.verified.txt | 22 +++++++++++++++++++ ...ifyOpenApiDocumentIsInvariant.verified.txt | 22 +++++++++++++++++++ 5 files changed, 90 insertions(+) diff --git a/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs b/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs index 0786ee08c952..ff95cdb92faf 100644 --- a/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs +++ b/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs @@ -77,6 +77,8 @@ public static IEndpointRouteBuilder MapSchemasEndpoints(this IEndpointRouteBuild Id = "id", RequiredNested = new NestedModel { Name = "nested" } })); + // Mirrors the array-of-nullable-elements parameter scenario above, but as a response body. + schemas.MapGet("/response-array-with-nullable-element", () => TypedResults.Ok(new TestEnum?[] { TestEnum.Value1, null })); schemas.MapGet("/optional-with-default", () => TypedResults.Ok(new ModelWithDefaults())); schemas.MapGet("/nullable-enum-response", () => TypedResults.Ok(new EnumNullableModel diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt index f8d06edde4f5..100d1d2ebe66 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt @@ -842,6 +842,28 @@ } } }, + "/schemas-by-ref/response-array-with-nullable-element": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TestEnum" + } + } + } + } + } + } + } + }, "/schemas-by-ref/optional-with-default": { "get": { "tags": [ diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt index a3b8aaea88b4..90a2bf5daff3 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt @@ -836,6 +836,28 @@ } } }, + "/schemas-by-ref/response-array-with-nullable-element": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TestEnum" + } + } + } + } + } + } + } + }, "/schemas-by-ref/optional-with-default": { "get": { "tags": [ diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt index 3a38ec720e67..9e0d214edbf2 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt @@ -836,6 +836,28 @@ } } }, + "/schemas-by-ref/response-array-with-nullable-element": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TestEnum" + } + } + } + } + } + } + } + }, "/schemas-by-ref/optional-with-default": { "get": { "tags": [ 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 ef886adffd88..81644fc36df9 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 @@ -1831,6 +1831,28 @@ } } }, + "/schemas-by-ref/response-array-with-nullable-element": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TestEnum" + } + } + } + } + } + } + } + }, "/schemas-by-ref/optional-with-default": { "get": { "tags": [