diff --git a/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs b/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs index e59a2896e741..ff95cdb92faf 100644 --- a/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs +++ b/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs @@ -67,6 +67,19 @@ 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" } + })); + // 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/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 20cf84d7459f..406aeda1887f 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/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..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 @@ -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,73 @@ } } }, + "/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/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 9968124b8c44..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 @@ -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,71 @@ } } }, + "/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/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 7101b0f7e1cc..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 @@ -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,71 @@ } } }, + "/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/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 7ecc0814fb8d..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 @@ -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,71 @@ } } }, + "/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/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/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],