From 6cc459263ab55f74da1d46f39b6ffeebc4226643 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Sat, 16 Aug 2025 12:44:24 -0700 Subject: [PATCH 1/8] Use allOf to model nullable return types --- .../src/Extensions/JsonTypeInfoExtensions.cs | 6 + .../src/Extensions/OpenApiSchemaExtensions.cs | 21 ++ src/OpenApi/src/Extensions/TypeExtensions.cs | 40 ++++ .../src/Services/OpenApiDocumentService.cs | 9 +- .../Services/Schemas/OpenApiSchemaService.cs | 15 ++ .../OpenApiSchemaService.ResponseSchemas.cs | 215 ++++++++++++++++++ 6 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 src/OpenApi/src/Extensions/OpenApiSchemaExtensions.cs diff --git a/src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs b/src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs index be025a61b529..a97a2226d5cf 100644 --- a/src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs +++ b/src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs @@ -108,6 +108,12 @@ internal static string GetSchemaReferenceId(this Type type, JsonSerializerOption return $"{typeName}Of{propertyNames}"; } + // Special handling for nullable value types + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)) + { + return type.GetGenericArguments()[0].GetSchemaReferenceId(options); + } + // Special handling for generic types that are collections // Generic types become a concatenation of the generic type name and the type arguments if (type.IsGenericType) diff --git a/src/OpenApi/src/Extensions/OpenApiSchemaExtensions.cs b/src/OpenApi/src/Extensions/OpenApiSchemaExtensions.cs new file mode 100644 index 000000000000..c0cc8580e0ef --- /dev/null +++ b/src/OpenApi/src/Extensions/OpenApiSchemaExtensions.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.OpenApi; + +internal static class OpenApiSchemaExtensions +{ + private static readonly OpenApiSchema _nullSchema = new() { Type = JsonSchemaType.Null }; + + public static IOpenApiSchema CreateAllOfNullableWrapper(this IOpenApiSchema originalSchema) + { + return new OpenApiSchema + { + AllOf = + [ + _nullSchema, + originalSchema + ] + }; + } +} diff --git a/src/OpenApi/src/Extensions/TypeExtensions.cs b/src/OpenApi/src/Extensions/TypeExtensions.cs index e2ba5f500c63..c5ebddb4dbf7 100644 --- a/src/OpenApi/src/Extensions/TypeExtensions.cs +++ b/src/OpenApi/src/Extensions/TypeExtensions.cs @@ -1,6 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Linq; +using System.Reflection; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Controllers; + namespace Microsoft.AspNetCore.OpenApi; internal static class TypeExtensions @@ -30,4 +35,39 @@ public static bool IsJsonPatchDocument(this Type type) return false; } + + public static bool ShouldApplyNullableResponseSchema(this ApiResponseType apiResponseType, ApiDescription apiDescription) + { + // Get the MethodInfo from the ActionDescriptor + var responseType = apiResponseType.Type; + var methodInfo = apiDescription.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor + ? controllerActionDescriptor.MethodInfo + : apiDescription.ActionDescriptor.EndpointMetadata.OfType().SingleOrDefault(); + + if (methodInfo is null) + { + return false; + } + + var returnType = methodInfo.ReturnType; + if (returnType.IsGenericType && + (returnType.GetGenericTypeDefinition() == typeof(Task<>) || returnType.GetGenericTypeDefinition() == typeof(ValueTask<>))) + { + returnType = returnType.GetGenericArguments()[0]; + } + if (returnType != responseType) + { + return false; + } + + if (returnType.IsValueType) + { + return apiResponseType.ModelMetadata?.IsNullableValueType ?? false; + } + + var nullabilityInfoContext = new NullabilityInfoContext(); + var nullabilityInfo = nullabilityInfoContext.Create(methodInfo.ReturnParameter); + + return nullabilityInfo.WriteState == NullabilityState.Nullable; + } } diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index 5308dc3792a1..5d3a4fd1691f 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -423,7 +423,14 @@ private async Task GetResponseAsync( .Select(responseFormat => responseFormat.MediaType); foreach (var contentType in apiResponseFormatContentTypes) { - var schema = apiResponseType.Type is { } type ? await _componentService.GetOrCreateSchemaAsync(document, type, scopedServiceProvider, schemaTransformers, null, cancellationToken) : new OpenApiSchema(); + IOpenApiSchema schema = new OpenApiSchema(); + if (apiResponseType.Type is { } responseType) + { + schema = await _componentService.GetOrCreateSchemaAsync(document, responseType, scopedServiceProvider, schemaTransformers, null, cancellationToken); + schema = apiResponseType.ShouldApplyNullableResponseSchema(apiDescription) + ? schema.CreateAllOfNullableWrapper() + : schema; + } response.Content[contentType] = new OpenApiMediaType { Schema = schema }; } diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index d35ab8bb449e..94b414fada2b 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -97,6 +97,21 @@ internal sealed class OpenApiSchemaService( schema.ApplyPrimitiveTypesAndFormats(context, createSchemaReferenceId); schema.ApplySchemaReferenceId(context, createSchemaReferenceId); schema.MapPolymorphismOptionsToDiscriminator(context, createSchemaReferenceId); + if (schema[OpenApiConstants.SchemaId] is { } schemaId && schema[OpenApiSchemaKeywords.TypeKeyword] is JsonArray typeArray) + { + // Remove null from union types when present + for (var i = typeArray.Count - 1; i >= 0; i--) + { + if (typeArray[i]?.GetValue() == "null") + { + typeArray.RemoveAt(i); + } + } + if (typeArray.Count == 1) + { + schema[OpenApiSchemaKeywords.TypeKeyword] = typeArray[0]?.GetValue(); + } + } if (context.PropertyInfo is { } jsonPropertyInfo) { schema.ApplyNullabilityContextInfo(jsonPropertyInfo); diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs index cabbd30d0c08..df1adfbf0263 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs @@ -174,6 +174,114 @@ public async Task GetOpenApiResponse_HandlesNullablePocoResponse() builder.MapGet("/api", GetTodo); #nullable restore + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api"].Operations[HttpMethod.Get]; + var responses = Assert.Single(operation.Responses); + var response = responses.Value; + Assert.True(response.Content.TryGetValue("application/json", out var mediaType)); + var schema = mediaType.Schema; + Assert.NotNull(schema.AllOf); + Assert.Equal(2, schema.AllOf.Count); + // Check that the allOf consists of a nullable schema and the GetTodo schema + Assert.Collection(schema.AllOf, + item => + { + Assert.NotNull(item); + Assert.Equal(JsonSchemaType.Null, item.Type); + }, + item => + { + Assert.NotNull(item); + Assert.Equal(JsonSchemaType.Object, item.Type); + Assert.Collection(item.Properties, + property => + { + Assert.Equal("id", property.Key); + Assert.Equal(JsonSchemaType.Integer, property.Value.Type); + Assert.Equal("int32", property.Value.Format); + }, + property => + { + Assert.Equal("title", property.Key); + Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, property.Value.Type); + }, + property => + { + Assert.Equal("completed", property.Key); + Assert.Equal(JsonSchemaType.Boolean, property.Value.Type); + }, + property => + { + Assert.Equal("createdAt", property.Key); + Assert.Equal(JsonSchemaType.String, property.Value.Type); + Assert.Equal("date-time", property.Value.Format); + }); + }); + + }); + } + + [Fact] + public async Task GetOpenApiResponse_HandlesNullablePocoTaskResponse() + { + // Arrange + var builder = CreateBuilder(); + + // Act +#nullable enable + static Task GetTodoAsync() => Task.FromResult(Random.Shared.Next() < 0.5 ? new Todo(1, "Test Title", true, DateTime.Now) : null); + builder.MapGet("/api", GetTodoAsync); +#nullable restore + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api"].Operations[HttpMethod.Get]; + var responses = Assert.Single(operation.Responses); + var response = responses.Value; + Assert.True(response.Content.TryGetValue("application/json", out var mediaType)); + var schema = mediaType.Schema; + Assert.Equal(JsonSchemaType.Object, schema.Type); + Assert.Collection(schema.Properties, + property => + { + Assert.Equal("id", property.Key); + Assert.Equal(JsonSchemaType.Integer, property.Value.Type); + Assert.Equal("int32", property.Value.Format); + }, + property => + { + Assert.Equal("title", property.Key); + Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, property.Value.Type); + }, + property => + { + Assert.Equal("completed", property.Key); + Assert.Equal(JsonSchemaType.Boolean, property.Value.Type); + }, + property => + { + Assert.Equal("createdAt", property.Key); + Assert.Equal(JsonSchemaType.String, property.Value.Type); + Assert.Equal("date-time", property.Value.Format); + }); + }); + } + + [Fact] + public async Task GetOpenApiResponse_HandlesNullablePocoValueTaskResponse() + { + // Arrange + var builder = CreateBuilder(); + + // Act +#nullable enable + static ValueTask GetTodoValueTaskAsync() => ValueTask.FromResult(Random.Shared.Next() < 0.5 ? new Todo(1, "Test Title", true, DateTime.Now) : null); + builder.MapGet("/api", GetTodoValueTaskAsync); +#nullable restore + // Assert await VerifyOpenApiDocument(builder, document => { @@ -231,6 +339,95 @@ await VerifyOpenApiDocument(builder, document => }); } + [Fact] + public async Task GetOpenApiResponse_HandlesNullableValueTypeResponse() + { + // Arrange + var builder = CreateBuilder(); + + // Act +#nullable enable + static Point? GetNullablePoint() => Random.Shared.Next() < 0.5 ? new Point { X = 10, Y = 20 } : null; + builder.MapGet("/api/nullable-point", GetNullablePoint); + + static Coordinate? GetNullableCoordinate() => Random.Shared.Next() < 0.5 ? new Coordinate(1.5, 2.5) : null; + builder.MapGet("/api/nullable-coordinate", GetNullableCoordinate); +#nullable restore + + // Assert + await VerifyOpenApiDocument(builder, document => + { + // Verify nullable Point response + var pointOperation = document.Paths["/api/nullable-point"].Operations[HttpMethod.Get]; + var pointResponses = Assert.Single(pointOperation.Responses); + var pointResponse = pointResponses.Value; + Assert.True(pointResponse.Content.TryGetValue("application/json", out var pointMediaType)); + var pointSchema = pointMediaType.Schema; + Assert.NotNull(pointSchema.AllOf); + Assert.Equal(2, pointSchema.AllOf.Count); + Assert.Collection(pointSchema.AllOf, + item => + { + Assert.NotNull(item); + Assert.Equal(JsonSchemaType.Null, item.Type); + }, + item => + { + Assert.NotNull(item); + Assert.Equal(JsonSchemaType.Object, item.Type); + Assert.Collection(item.Properties, + property => + { + Assert.Equal("x", property.Key); + Assert.Equal(JsonSchemaType.Integer, property.Value.Type); + Assert.Equal("int32", property.Value.Format); + }, + property => + { + Assert.Equal("y", property.Key); + Assert.Equal(JsonSchemaType.Integer, property.Value.Type); + Assert.Equal("int32", property.Value.Format); + }); + }); + + // Verify nullable Coordinate response + var coordinateOperation = document.Paths["/api/nullable-coordinate"].Operations[HttpMethod.Get]; + var coordinateResponses = Assert.Single(coordinateOperation.Responses); + var coordinateResponse = coordinateResponses.Value; + Assert.True(coordinateResponse.Content.TryGetValue("application/json", out var coordinateMediaType)); + var coordinateSchema = coordinateMediaType.Schema; + Assert.NotNull(coordinateSchema.AllOf); + Assert.Equal(2, coordinateSchema.AllOf.Count); + Assert.Collection(coordinateSchema.AllOf, + item => + { + Assert.NotNull(item); + Assert.Equal(JsonSchemaType.Null, item.Type); + }, + item => + { + Assert.NotNull(item); + Assert.Equal(JsonSchemaType.Object, item.Type); + Assert.Collection(item.Properties, + property => + { + Assert.Equal("latitude", property.Key); + Assert.Equal(JsonSchemaType.Number, property.Value.Type); + Assert.Equal("double", property.Value.Format); + }, + property => + { + Assert.Equal("longitude", property.Key); + Assert.Equal(JsonSchemaType.Number, property.Value.Type); + Assert.Equal("double", property.Value.Format); + }); + }); + + // Assert that Point and Coordinates are the only schemas defined at the top-level + Assert.Equal(["Coordinate", "Point"], [.. document.Components.Schemas.Keys]); + }); + } + [Fact] public async Task GetOpenApiResponse_HandlesInheritedTypeResponse() { @@ -732,4 +929,22 @@ private class ClassWithObjectProperty [DefaultValue(32)] public object AnotherObject { get; set; } } + + private struct Point + { + public int X { get; set; } + public int Y { get; set; } + } + + private readonly struct Coordinate + { + public double Latitude { get; } + public double Longitude { get; } + + public Coordinate(double latitude, double longitude) + { + Latitude = latitude; + Longitude = longitude; + } + } } From 0c90cc95074950493fa5977236d40e086ec95c61 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Sat, 16 Aug 2025 15:27:36 -0700 Subject: [PATCH 2/8] Use allOf to model nullable parameter types --- .../Extensions/JsonNodeSchemaExtensions.cs | 29 --- src/OpenApi/src/Extensions/TypeExtensions.cs | 25 +++ .../src/Services/OpenApiDocumentService.cs | 6 +- .../OpenApiSchemaService.ParameterSchemas.cs | 7 + ...OpenApiSchemaService.RequestBodySchemas.cs | 182 ++++++++++++++++++ 5 files changed, 219 insertions(+), 30 deletions(-) diff --git a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs index feb32d33b8e0..915323012cd7 100644 --- a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs +++ b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs @@ -195,11 +195,6 @@ internal static void ApplyDefaultValue(this JsonNode schema, object? defaultValu /// underlying schema generator does not support this, we need to manually apply the /// supported formats to the schemas associated with the generated type. /// - /// Whereas JsonSchema represents nullable types via `type: ["string", "null"]`, OpenAPI - /// v3 exposes a nullable property on the schema. This method will set the nullable property - /// based on whether the underlying schema generator returned an array type containing "null" to - /// represent a nullable type or if the type was denoted as nullable from our lookup cache. - /// /// Note that this method targets and not because /// it is is designed to be invoked via the `OnGenerated` callback in the underlying schema generator as /// opposed to after the generated schemas have been mapped to OpenAPI schemas. @@ -349,8 +344,6 @@ internal static void ApplyParameterInfo(this JsonNode schema, ApiParameterDescri { schema.ApplyValidationAttributes(validationAttributes); } - - schema.ApplyNullabilityContextInfo(parameterInfo); } // Route constraints are only defined on parameters that are sourced from the path. Since // they are encoded in the route template, and not in the type information based to the underlying @@ -450,28 +443,6 @@ private static bool IsNonAbstractTypeWithoutDerivedTypeReference(JsonSchemaExpor && !polymorphismOptions.DerivedTypes.Any(type => type.DerivedType == context.TypeInfo.Type); } - /// - /// Support applying nullability status for reference types provided as a parameter. - /// - /// The produced by the underlying schema generator. - /// The associated with the schema. - internal static void ApplyNullabilityContextInfo(this JsonNode schema, ParameterInfo parameterInfo) - { - if (parameterInfo.ParameterType.IsValueType) - { - return; - } - - var nullabilityInfoContext = new NullabilityInfoContext(); - var nullabilityInfo = nullabilityInfoContext.Create(parameterInfo); - if (nullabilityInfo.WriteState == NullabilityState.Nullable - && MapJsonNodeToSchemaType(schema[OpenApiSchemaKeywords.TypeKeyword]) is { } schemaTypes - && !schemaTypes.HasFlag(JsonSchemaType.Null)) - { - schema[OpenApiSchemaKeywords.TypeKeyword] = (schemaTypes | JsonSchemaType.Null).ToString(); - } - } - /// /// Support applying nullability status for reference types provided as a property or field. /// diff --git a/src/OpenApi/src/Extensions/TypeExtensions.cs b/src/OpenApi/src/Extensions/TypeExtensions.cs index c5ebddb4dbf7..b20e0d0ca1a7 100644 --- a/src/OpenApi/src/Extensions/TypeExtensions.cs +++ b/src/OpenApi/src/Extensions/TypeExtensions.cs @@ -5,6 +5,7 @@ using System.Reflection; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Infrastructure; namespace Microsoft.AspNetCore.OpenApi; @@ -70,4 +71,28 @@ public static bool ShouldApplyNullableResponseSchema(this ApiResponseType apiRes return nullabilityInfo.WriteState == NullabilityState.Nullable; } + + public static bool ShouldApplyNullableRequestSchema(this ApiParameterDescription apiParameterDescription) + { + var parameterType = apiParameterDescription.Type; + if (parameterType is null) + { + return false; + } + + if (apiParameterDescription.ParameterDescriptor is not IParameterInfoParameterDescriptor { ParameterInfo: { } parameterInfo }) + { + return false; + } + + if (parameterType.IsValueType) + { + return apiParameterDescription.ModelMetadata?.IsNullableValueType ?? false; + } + + var nullabilityInfoContext = new NullabilityInfoContext(); + var nullabilityInfo = nullabilityInfoContext.Create(parameterInfo); + + return nullabilityInfo.WriteState == NullabilityState.Nullable; + } } diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index 5d3a4fd1691f..40a8ba793cec 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -751,7 +751,11 @@ private async Task GetJsonRequestBody( foreach (var requestFormat in supportedRequestFormats) { var contentType = requestFormat.MediaType; - requestBody.Content[contentType] = new OpenApiMediaType { Schema = await _componentService.GetOrCreateSchemaAsync(document, bodyParameter.Type, scopedServiceProvider, schemaTransformers, bodyParameter, cancellationToken: cancellationToken) }; + var schema = await _componentService.GetOrCreateSchemaAsync(document, bodyParameter.Type, scopedServiceProvider, schemaTransformers, bodyParameter, cancellationToken: cancellationToken); + schema = bodyParameter.ShouldApplyNullableRequestSchema() + ? schema.CreateAllOfNullableWrapper() + : schema; + requestBody.Content[contentType] = new OpenApiMediaType { Schema = schema }; } return requestBody; 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 ede9a0b3d1b2..292354dd5248 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 @@ -451,18 +451,25 @@ await VerifyOpenApiDocument(builder, document => }); } +#nullable enable public static object[][] ArrayBasedQueryParameters => [ [(int[] id) => { }, JsonSchemaType.Integer, false], [(int?[] id) => { }, JsonSchemaType.Integer, true], [(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], [(DateTime[] id) => { }, JsonSchemaType.String, false], [(DateTime?[] id) => { }, JsonSchemaType.String, true], [(DateTimeOffset[] id) => { }, JsonSchemaType.String, false], [(DateTimeOffset?[] id) => { }, JsonSchemaType.String, true], [(Uri[] id) => { }, JsonSchemaType.String, false], ]; +#nullable restore [Theory] [MemberData(nameof(ArrayBasedQueryParameters))] diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.RequestBodySchemas.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.RequestBodySchemas.cs index 016ea0663edc..29d673800acc 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.RequestBodySchemas.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.RequestBodySchemas.cs @@ -464,6 +464,188 @@ await VerifyOpenApiDocument(builder, document => }); } + [Fact] + public async Task GetOpenApiRequestBody_HandlesNullableParameterWithAllOf() + { + // Arrange + var builder = CreateBuilder(); + + // Act +#nullable enable + builder.MapPost("/api/nullable-todo", (Todo? todo) => { }); + builder.MapPost("/api/nullable-point", (Point? point) => { }); +#nullable restore + + // Assert + await VerifyOpenApiDocument(builder, document => + { + // Verify nullable Todo parameter + var todoOperation = document.Paths["/api/nullable-todo"].Operations[HttpMethod.Post]; + var todoRequestBody = todoOperation.RequestBody; + var todoContent = Assert.Single(todoRequestBody.Content); + Assert.Equal("application/json", todoContent.Key); + var todoSchema = todoContent.Value.Schema; + Assert.NotNull(todoSchema.AllOf); + Assert.Equal(2, todoSchema.AllOf.Count); + Assert.Collection(todoSchema.AllOf, + item => + { + Assert.NotNull(item); + Assert.Equal(JsonSchemaType.Null, item.Type); + }, + item => + { + Assert.NotNull(item); + Assert.Equal(JsonSchemaType.Object, item.Type); + Assert.Collection(item.Properties, + property => + { + Assert.Equal("id", property.Key); + Assert.Equal(JsonSchemaType.Integer, property.Value.Type); + Assert.Equal("int32", property.Value.Format); + }, + property => + { + Assert.Equal("title", property.Key); + Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, property.Value.Type); + }, + property => + { + Assert.Equal("completed", property.Key); + Assert.Equal(JsonSchemaType.Boolean, property.Value.Type); + }, + property => + { + Assert.Equal("createdAt", property.Key); + Assert.Equal(JsonSchemaType.String, property.Value.Type); + Assert.Equal("date-time", property.Value.Format); + }); + }); + + // Verify nullable Point parameter + var pointOperation = document.Paths["/api/nullable-point"].Operations[HttpMethod.Post]; + var pointRequestBody = pointOperation.RequestBody; + var pointContent = Assert.Single(pointRequestBody.Content); + Assert.Equal("application/json", pointContent.Key); + var pointSchema = pointContent.Value.Schema; + Assert.NotNull(pointSchema.AllOf); + Assert.Equal(2, pointSchema.AllOf.Count); + Assert.Collection(pointSchema.AllOf, + item => + { + Assert.NotNull(item); + Assert.Equal(JsonSchemaType.Null, item.Type); + }, + item => + { + Assert.NotNull(item); + Assert.Equal(JsonSchemaType.Object, item.Type); + Assert.Collection(item.Properties, + property => + { + Assert.Equal("x", property.Key); + Assert.Equal(JsonSchemaType.Integer, property.Value.Type); + Assert.Equal("int32", property.Value.Format); + }, + property => + { + Assert.Equal("y", property.Key); + Assert.Equal(JsonSchemaType.Integer, property.Value.Type); + Assert.Equal("int32", property.Value.Format); + }); + }); + + Assert.Equal(["Point", "Todo"], [.. document.Components.Schemas.Keys]); + Assert.Collection(document.Components.Schemas.Values, + item => Assert.Equal(JsonSchemaType.Object, item.Type), + item => Assert.Equal(JsonSchemaType.Object, item.Type)); + }); + } + + [Fact] + public async Task GetOpenApiRequestBody_HandlesNullableCollectionParametersWithAllOf() + { + // Arrange + var builder = CreateBuilder(); + + // Act +#nullable enable + builder.MapPost("/api/nullable-array", (Todo[]? todos) => { }); + builder.MapPost("/api/nullable-list", (List? todoList) => { }); + builder.MapPost("/api/nullable-enumerable", (IEnumerable? todoEnumerable) => { }); +#nullable restore + + // Assert + await VerifyOpenApiDocument(builder, document => + { + // Verify nullable array parameter - verify actual behavior with AllOf + var arrayOperation = document.Paths["/api/nullable-array"].Operations[HttpMethod.Post]; + var arrayRequestBody = arrayOperation.RequestBody; + var arrayContent = Assert.Single(arrayRequestBody.Content); + Assert.Equal("application/json", arrayContent.Key); + var arraySchema = arrayContent.Value.Schema; + Assert.NotNull(arraySchema.AllOf); // AllOf IS used for nullable collections + Assert.Equal(2, arraySchema.AllOf.Count); + Assert.Collection(arraySchema.AllOf, + item => + { + Assert.NotNull(item); + Assert.Equal(JsonSchemaType.Null, item.Type); + }, + item => + { + Assert.NotNull(item); + Assert.Equal(JsonSchemaType.Array, item.Type); + Assert.NotNull(item.Items); + Assert.Equal("Todo", ((OpenApiSchemaReference)item.Items).Reference.Id); + }); + + // Verify nullable List parameter - verify actual behavior with AllOf + var listOperation = document.Paths["/api/nullable-list"].Operations[HttpMethod.Post]; + var listRequestBody = listOperation.RequestBody; + var listContent = Assert.Single(listRequestBody.Content); + Assert.Equal("application/json", listContent.Key); + var listSchema = listContent.Value.Schema; + Assert.NotNull(listSchema.AllOf); // AllOf IS used for nullable collections + Assert.Equal(2, listSchema.AllOf.Count); + Assert.Collection(listSchema.AllOf, + item => + { + Assert.NotNull(item); + Assert.Equal(JsonSchemaType.Null, item.Type); + }, + item => + { + Assert.NotNull(item); + Assert.Equal(JsonSchemaType.Array, item.Type); + Assert.NotNull(item.Items); + Assert.Equal("Todo", ((OpenApiSchemaReference)item.Items).Reference.Id); + }); + + // Verify nullable IEnumerable parameter - verify actual behavior with AllOf + var enumerableOperation = document.Paths["/api/nullable-enumerable"].Operations[HttpMethod.Post]; + var enumerableRequestBody = enumerableOperation.RequestBody; + var enumerableContent = Assert.Single(enumerableRequestBody.Content); + Assert.Equal("application/json", enumerableContent.Key); + var enumerableSchema = enumerableContent.Value.Schema; + Assert.NotNull(enumerableSchema.AllOf); // AllOf IS used for nullable collections + Assert.Equal(2, enumerableSchema.AllOf.Count); + Assert.Collection(enumerableSchema.AllOf, + item => + { + Assert.NotNull(item); + Assert.Equal(JsonSchemaType.Null, item.Type); + }, + item => + { + Assert.NotNull(item); + Assert.Equal(JsonSchemaType.Array, item.Type); + Assert.NotNull(item.Items); + Assert.Equal("Todo", ((OpenApiSchemaReference)item.Items).Reference.Id); + }); + }); + } + [Fact] public async Task SupportsNestedTypes() { From 71366d4cac20ac9671496d63f4f2733defd86e08 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Sun, 17 Aug 2025 12:05:47 -0700 Subject: [PATCH 3/8] Use allOf for nullable properties --- .../Extensions/JsonNodeSchemaExtensions.cs | 24 +++++++++++++++ src/OpenApi/src/Extensions/TypeExtensions.cs | 13 ++++++++- .../src/Schemas/OpenApiJsonSchema.Helpers.cs | 5 ++++ src/OpenApi/src/Services/OpenApiConstants.cs | 1 + .../Services/Schemas/OpenApiSchemaService.cs | 29 ++++++++----------- ...t_documentName=schemas-by-ref.verified.txt | 12 ++++++-- ...t_documentName=schemas-by-ref.verified.txt | 14 +++++---- ...ifyOpenApiDocumentIsInvariant.verified.txt | 14 +++++---- .../OpenApiSchemaService.ResponseSchemas.cs | 6 ++-- .../OpenApiSchemaReferenceTransformerTests.cs | 19 ++++++------ 10 files changed, 93 insertions(+), 44 deletions(-) diff --git a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs index 915323012cd7..1ae906a14ae9 100644 --- a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs +++ b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs @@ -460,6 +460,30 @@ internal static void ApplyNullabilityContextInfo(this JsonNode schema, JsonPrope schema[OpenApiSchemaKeywords.TypeKeyword] = (schemaTypes | JsonSchemaType.Null).ToString(); } } + if (schema[OpenApiConstants.SchemaId] is not null && + propertyInfo.PropertyType != typeof(object) && propertyInfo.ShouldApplyNullablePropertySchema()) + { + schema[OpenApiConstants.NullableProperty] = true; + } + } + + internal static void PruneNullTypeForReferencedTypes(this JsonNode schema) + { + if (schema[OpenApiConstants.SchemaId] is not null && + schema[OpenApiSchemaKeywords.TypeKeyword] is JsonArray typeArray) + { + for (var i = typeArray.Count - 1; i >= 0; i--) + { + if (typeArray[i]?.GetValue() == "null") + { + typeArray.RemoveAt(i); + } + } + if (typeArray.Count == 1) + { + schema[OpenApiSchemaKeywords.TypeKeyword] = typeArray[0]?.GetValue(); + } + } } private static JsonSchemaType? MapJsonNodeToSchemaType(JsonNode? jsonNode) diff --git a/src/OpenApi/src/Extensions/TypeExtensions.cs b/src/OpenApi/src/Extensions/TypeExtensions.cs index b20e0d0ca1a7..0636347bf5bc 100644 --- a/src/OpenApi/src/Extensions/TypeExtensions.cs +++ b/src/OpenApi/src/Extensions/TypeExtensions.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Reflection; +using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Infrastructure; @@ -68,7 +69,6 @@ public static bool ShouldApplyNullableResponseSchema(this ApiResponseType apiRes var nullabilityInfoContext = new NullabilityInfoContext(); var nullabilityInfo = nullabilityInfoContext.Create(methodInfo.ReturnParameter); - return nullabilityInfo.WriteState == NullabilityState.Nullable; } @@ -92,7 +92,18 @@ public static bool ShouldApplyNullableRequestSchema(this ApiParameterDescription var nullabilityInfoContext = new NullabilityInfoContext(); var nullabilityInfo = nullabilityInfoContext.Create(parameterInfo); + return nullabilityInfo.WriteState == NullabilityState.Nullable; + } + public static bool ShouldApplyNullablePropertySchema(this JsonPropertyInfo jsonPropertyInfo) + { + if (jsonPropertyInfo.AttributeProvider is not PropertyInfo propertyInfo) + { + return false; + } + + var nullabilityInfoContext = new NullabilityInfoContext(); + var nullabilityInfo = nullabilityInfoContext.Create(propertyInfo); return nullabilityInfo.WriteState == NullabilityState.Nullable; } } diff --git a/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs b/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs index 28b8de3eaa89..877ac70010db 100644 --- a/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs +++ b/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs @@ -333,6 +333,11 @@ public static void ReadProperty(ref Utf8JsonReader reader, string propertyName, schema.Metadata ??= new Dictionary(); schema.Metadata.Add(OpenApiConstants.SchemaId, reader.GetString() ?? string.Empty); break; + case OpenApiConstants.NullableProperty: + reader.Read(); + schema.Metadata ??= new Dictionary(); + schema.Metadata.Add(OpenApiConstants.NullableProperty, reader.GetBoolean()); + break; // OpenAPI does not support the `const` keyword in its schema implementation, so // we map it to its closest approximation, an enum with a single value, here. case OpenApiSchemaKeywords.ConstKeyword: diff --git a/src/OpenApi/src/Services/OpenApiConstants.cs b/src/OpenApi/src/Services/OpenApiConstants.cs index 0bb51a9bbd32..df4228633556 100644 --- a/src/OpenApi/src/Services/OpenApiConstants.cs +++ b/src/OpenApi/src/Services/OpenApiConstants.cs @@ -17,6 +17,7 @@ internal static class OpenApiConstants internal const string RefExampleAnnotation = "x-ref-example"; internal const string RefKeyword = "$ref"; internal const string RefPrefix = "#"; + internal const string NullableProperty = "x-is-nullable-property"; internal const string DefaultOpenApiResponseKey = "default"; // Since there's a finite set of HTTP methods that can be included in a given // OpenApiPaths, we can pre-allocate an array of these methods and use a direct diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index 94b414fada2b..b0eb5bde741f 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -97,21 +97,6 @@ internal sealed class OpenApiSchemaService( schema.ApplyPrimitiveTypesAndFormats(context, createSchemaReferenceId); schema.ApplySchemaReferenceId(context, createSchemaReferenceId); schema.MapPolymorphismOptionsToDiscriminator(context, createSchemaReferenceId); - if (schema[OpenApiConstants.SchemaId] is { } schemaId && schema[OpenApiSchemaKeywords.TypeKeyword] is JsonArray typeArray) - { - // Remove null from union types when present - for (var i = typeArray.Count - 1; i >= 0; i--) - { - if (typeArray[i]?.GetValue() == "null") - { - typeArray.RemoveAt(i); - } - } - if (typeArray.Count == 1) - { - schema[OpenApiSchemaKeywords.TypeKeyword] = typeArray[0]?.GetValue(); - } - } if (context.PropertyInfo is { } jsonPropertyInfo) { schema.ApplyNullabilityContextInfo(jsonPropertyInfo); @@ -147,7 +132,7 @@ internal sealed class OpenApiSchemaService( } } } - + schema.PruneNullTypeForReferencedTypes(); return schema; } }; @@ -296,7 +281,17 @@ internal static IOpenApiSchema ResolveReferenceForSchema(OpenApiDocument documen { foreach (var property in schema.Properties) { - schema.Properties[property.Key] = ResolveReferenceForSchema(document, property.Value, rootSchemaId); + if (property.Value is OpenApiSchema targetSchema && + targetSchema.Metadata?.TryGetValue(OpenApiConstants.NullableProperty, out var isNullableProperty) == true && + isNullableProperty is true) + { + var resolvedProperty = ResolveReferenceForSchema(document, property.Value, rootSchemaId); + schema.Properties[property.Key] = resolvedProperty.CreateAllOfNullableWrapper(); + } + else + { + schema.Properties[property.Key] = ResolveReferenceForSchema(document, property.Value, rootSchemaId); + } } } 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 86d6b730e372..7ba3d49f6f91 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 @@ -1022,8 +1022,7 @@ "user": { "$ref": "#/components/schemas/RefUser" } - }, - "nullable": true + } }, "RefUser": { "type": "object", @@ -1124,7 +1123,14 @@ "$ref": "#/components/schemas/RefProfile" }, "secondaryUser": { - "$ref": "#/components/schemas/RefProfile" + "allOf": [ + { + "nullable": true + }, + { + "$ref": "#/components/schemas/RefProfile" + } + ] } } }, 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 00e055a69541..63f1ec754f26 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 @@ -1027,10 +1027,7 @@ "required": [ "user" ], - "type": [ - "null", - "object" - ], + "type": "object", "properties": { "user": { "$ref": "#/components/schemas/RefUser" @@ -1136,7 +1133,14 @@ "$ref": "#/components/schemas/RefProfile" }, "secondaryUser": { - "$ref": "#/components/schemas/RefProfile" + "allOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/RefProfile" + } + ] } } }, 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 d70cdd320341..d3cf7655ca12 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 @@ -1840,10 +1840,7 @@ "required": [ "user" ], - "type": [ - "null", - "object" - ], + "type": "object", "properties": { "user": { "$ref": "#/components/schemas/RefUser" @@ -1949,7 +1946,14 @@ "$ref": "#/components/schemas/RefProfile" }, "secondaryUser": { - "$ref": "#/components/schemas/RefProfile" + "allOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/RefProfile" + } + ] } } }, diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs index df1adfbf0263..389c4cf23d2c 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs @@ -510,7 +510,7 @@ await VerifyOpenApiDocument(builder, document => { Assert.Equal("value", property.Key); var propertyValue = property.Value; - Assert.Equal(JsonSchemaType.Null | JsonSchemaType.Object, propertyValue.Type); + Assert.Equal(JsonSchemaType.Object, propertyValue.Type); Assert.Collection(propertyValue.Properties, property => { @@ -536,7 +536,7 @@ await VerifyOpenApiDocument(builder, document => { Assert.Equal("error", property.Key); var propertyValue = property.Value; - Assert.Equal(JsonSchemaType.Null | JsonSchemaType.Object, propertyValue.Type); + Assert.Equal(JsonSchemaType.Object, propertyValue.Type); Assert.Collection(propertyValue.Properties, property => { Assert.Equal("code", property.Key); @@ -624,7 +624,7 @@ await VerifyOpenApiDocument(builder, document => { Assert.Equal("todo", property.Key); var propertyValue = property.Value; - Assert.Equal(JsonSchemaType.Null | JsonSchemaType.Object, propertyValue.Type); + Assert.Equal(JsonSchemaType.Object, propertyValue.Type); Assert.Collection(propertyValue.Properties, property => { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/Implementations/OpenApiSchemaReferenceTransformerTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/Implementations/OpenApiSchemaReferenceTransformerTests.cs index 67d4bf32160d..b1c76676dd70 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/Implementations/OpenApiSchemaReferenceTransformerTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/Implementations/OpenApiSchemaReferenceTransformerTests.cs @@ -501,10 +501,7 @@ await VerifyOpenApiDocument(builder, document => serializedSchema = writer.ToString(); Assert.Equal(""" { - "type": [ - "null", - "object" - ], + "type": "object", "properties": { "address": { "$ref": "#/components/schemas/AddressDto" @@ -519,10 +516,7 @@ await VerifyOpenApiDocument(builder, document => serializedSchema = writer.ToString(); Assert.Equal(""" { - "type": [ - "null", - "object" - ], + "type": "object", "properties": { "relatedLocation": { "$ref": "#/components/schemas/LocationDto" @@ -985,7 +979,10 @@ await VerifyOpenApiDocument(builder, document => // Check secondaryUser property (nullable RefProfile) var secondaryUserSchema = requestSchema.Properties!["secondaryUser"]; - Assert.Equal("RefProfile", ((OpenApiSchemaReference)secondaryUserSchema).Reference.Id); + Assert.NotNull(secondaryUserSchema.AllOf); + Assert.Collection(secondaryUserSchema.AllOf, + item => Assert.Equal(JsonSchemaType.Null, item.Type), + item => Assert.Equal("RefProfile", ((OpenApiSchemaReference)item).Reference.Id)); // Verify the RefProfile schema has a User property that references RefUser var userPropertySchema = primaryUserSchema.Properties!["user"]; @@ -998,10 +995,12 @@ await VerifyOpenApiDocument(builder, document => Assert.Contains("email", userSchemaContent.Properties?.Keys ?? []); // Both properties should reference the same RefProfile schema + var secondaryUserSchemaRef = secondaryUserSchema.AllOf.Last(); Assert.Equal(((OpenApiSchemaReference)primaryUserSchema).Reference.Id, - ((OpenApiSchemaReference)secondaryUserSchema).Reference.Id); + ((OpenApiSchemaReference)secondaryUserSchemaRef).Reference.Id); Assert.Equal(["RefProfile", "RefUser", "Subscription"], document.Components!.Schemas!.Keys.OrderBy(x => x)); + Assert.All(document.Components.Schemas.Values, item => Assert.False(item.Type?.HasFlag(JsonSchemaType.Null))); }); } From 7fe1e809e7cb8feeef497a58e7fc8dafd8417265 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Sun, 17 Aug 2025 13:10:25 -0700 Subject: [PATCH 4/8] Add test coverage for allOf with nullable --- .../sample/Endpoints/MapSchemasEndpoints.cs | 89 ++++ ...t_documentName=schemas-by-ref.verified.txt | 266 ++++++++++ ...t_documentName=schemas-by-ref.verified.txt | 286 +++++++++++ ...ifyOpenApiDocumentIsInvariant.verified.txt | 286 +++++++++++ .../OpenApiSchemaService.ParameterSchemas.cs | 91 ++++ .../OpenApiSchemaService.PropertySchemas.cs | 456 ++++++++++++++++++ ...OpenApiSchemaService.RequestBodySchemas.cs | 61 +++ .../OpenApiSchemaService.ResponseSchemas.cs | 119 +++++ 8 files changed, 1654 insertions(+) create mode 100644 src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.PropertySchemas.cs diff --git a/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs b/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs index 23591e8efb18..b956fe0735a2 100644 --- a/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs +++ b/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs @@ -46,6 +46,26 @@ public static IEndpointRouteBuilder MapSchemasEndpoints(this IEndpointRouteBuild schemas.MapPost("/config-with-generic-lists", (Config config) => Results.Ok(config)); schemas.MapPost("/project-response", (ProjectResponse project) => Results.Ok(project)); schemas.MapPost("/subscription", (Subscription subscription) => Results.Ok(subscription)); + + // Tests for allOf nullable behavior on responses and request bodies + schemas.MapGet("/nullable-response", () => TypedResults.Ok(new NullableResponseModel + { + RequiredProperty = "required", + NullableProperty = null, + NullableComplexProperty = null + })); + schemas.MapPost("/nullable-request", (NullableRequestModel? request) => Results.Ok(request)); + schemas.MapPost("/complex-nullable-hierarchy", (ComplexHierarchyModel model) => Results.Ok(model)); + + // Additional edge cases for nullable testing + schemas.MapPost("/nullable-array-elements", (NullableArrayModel model) => Results.Ok(model)); + schemas.MapGet("/optional-with-default", () => Results.Ok(new ModelWithDefaults())); + schemas.MapGet("/nullable-enum-response", () => Results.Ok(new EnumNullableModel + { + RequiredEnum = TestEnum.Value1, + NullableEnum = null + })); + return endpointRouteBuilder; } @@ -173,4 +193,73 @@ public sealed class RefUser public string Name { get; set; } = ""; public string Email { get; set; } = ""; } + + // Models for testing allOf nullable behavior + public sealed class NullableResponseModel + { + public required string RequiredProperty { get; set; } + public string? NullableProperty { get; set; } + public ComplexType? NullableComplexProperty { get; set; } + } + + public sealed class NullableRequestModel + { + public required string RequiredField { get; set; } + public string? OptionalField { get; set; } + public List? NullableList { get; set; } + public Dictionary? NullableDictionary { get; set; } + } + + // Complex hierarchy model for testing nested nullable properties + public sealed class ComplexHierarchyModel + { + public required string Id { get; set; } + public NestedModel? OptionalNested { get; set; } + public required NestedModel RequiredNested { get; set; } + public List? NullableListWithNullableItems { get; set; } + } + + public sealed class NestedModel + { + public required string Name { get; set; } + public int? OptionalValue { get; set; } + public ComplexType? DeepNested { get; set; } + } + + public sealed class ComplexType + { + public string? Description { get; set; } + public DateTime? Timestamp { get; set; } + } + + // Additional models for edge case testing + public sealed class NullableArrayModel + { + public string[]? NullableArray { get; set; } + public List ListWithNullableElements { get; set; } = []; + public Dictionary? NullableDictionaryWithNullableValues { get; set; } + } + + public sealed class ModelWithDefaults + { + public string PropertyWithDefault { get; set; } = "default"; + public string? NullableWithNull { get; set; } + public int NumberWithDefault { get; set; } = 42; + public bool BoolWithDefault { get; set; } = true; + } + + // Enum testing with nullable + public enum TestEnum + { + Value1, + Value2, + Value3 + } + + public sealed class EnumNullableModel + { + public required TestEnum RequiredEnum { get; set; } + public TestEnum? NullableEnum { get; set; } + public List ListOfNullableEnums { get; set; } = []; + } } 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 7ba3d49f6f91..c175039e9293 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 @@ -636,6 +636,121 @@ } } } + }, + "/schemas-by-ref/nullable-response": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NullableResponseModel" + } + } + } + } + } + } + }, + "/schemas-by-ref/nullable-request": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "nullable": true + }, + { + "$ref": "#/components/schemas/NullableRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/complex-nullable-hierarchy": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ComplexHierarchyModel" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/nullable-array-elements": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NullableArrayModel" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/optional-with-default": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/nullable-enum-response": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK" + } + } + } } }, "components": { @@ -707,6 +822,52 @@ } } }, + "ComplexHierarchyModel": { + "required": [ + "id", + "requiredNested" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "optionalNested": { + "allOf": [ + { + "nullable": true + }, + { + "$ref": "#/components/schemas/NestedModel" + } + ] + }, + "requiredNested": { + "$ref": "#/components/schemas/NestedModel" + }, + "nullableListWithNullableItems": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedModel" + }, + "nullable": true + } + } + }, + "ComplexType": { + "type": "object", + "properties": { + "description": { + "type": "string", + "nullable": true + }, + "timestamp": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + }, "Config": { "type": "object", "properties": { @@ -898,6 +1059,111 @@ } } }, + "NestedModel": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "optionalValue": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "deepNested": { + "allOf": [ + { + "nullable": true + }, + { + "$ref": "#/components/schemas/ComplexType" + } + ] + } + } + }, + "NullableArrayModel": { + "type": "object", + "properties": { + "nullableArray": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "listWithNullableElements": { + "type": "array", + "items": { + "type": "string" + } + }, + "nullableDictionaryWithNullableValues": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "nullable": true + } + } + }, + "NullableRequestModel": { + "required": [ + "requiredField" + ], + "type": "object", + "properties": { + "requiredField": { + "type": "string" + }, + "optionalField": { + "type": "string", + "nullable": true + }, + "nullableList": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "nullableDictionary": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "nullable": true + } + } + }, + "NullableResponseModel": { + "required": [ + "requiredProperty" + ], + "type": "object", + "properties": { + "requiredProperty": { + "type": "string" + }, + "nullableProperty": { + "type": "string", + "nullable": true + }, + "nullableComplexProperty": { + "allOf": [ + { + "nullable": true + }, + { + "$ref": "#/components/schemas/ComplexType" + } + ] + } + } + }, "ParentObject": { "type": "object", "properties": { 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 63f1ec754f26..999f999b81b1 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 @@ -636,6 +636,121 @@ } } } + }, + "/schemas-by-ref/nullable-response": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NullableResponseModel" + } + } + } + } + } + } + }, + "/schemas-by-ref/nullable-request": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NullableRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/complex-nullable-hierarchy": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ComplexHierarchyModel" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/nullable-array-elements": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NullableArrayModel" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/optional-with-default": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/nullable-enum-response": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK" + } + } + } } }, "components": { @@ -707,6 +822,58 @@ } } }, + "ComplexHierarchyModel": { + "required": [ + "id", + "requiredNested" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "optionalNested": { + "allOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NestedModel" + } + ] + }, + "requiredNested": { + "$ref": "#/components/schemas/NestedModel" + }, + "nullableListWithNullableItems": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/NestedModel" + } + } + } + }, + "ComplexType": { + "type": "object", + "properties": { + "description": { + "type": [ + "null", + "string" + ] + }, + "timestamp": { + "type": [ + "null", + "string" + ], + "format": "date-time" + } + } + }, "Config": { "type": "object", "properties": { @@ -908,6 +1075,125 @@ } } }, + "NestedModel": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "optionalValue": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "deepNested": { + "allOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ComplexType" + } + ] + } + } + }, + "NullableArrayModel": { + "type": "object", + "properties": { + "nullableArray": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + }, + "listWithNullableElements": { + "type": "array", + "items": { + "type": "string" + } + }, + "nullableDictionaryWithNullableValues": { + "type": [ + "null", + "object" + ], + "additionalProperties": { + "type": "string" + } + } + } + }, + "NullableRequestModel": { + "required": [ + "requiredField" + ], + "type": "object", + "properties": { + "requiredField": { + "type": "string" + }, + "optionalField": { + "type": [ + "null", + "string" + ] + }, + "nullableList": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + }, + "nullableDictionary": { + "type": [ + "null", + "object" + ], + "additionalProperties": { + "type": "string" + } + } + } + }, + "NullableResponseModel": { + "required": [ + "requiredProperty" + ], + "type": "object", + "properties": { + "requiredProperty": { + "type": "string" + }, + "nullableProperty": { + "type": [ + "null", + "string" + ] + }, + "nullableComplexProperty": { + "allOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ComplexType" + } + ] + } + } + }, "ParentObject": { "type": "object", "properties": { 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 d3cf7655ca12..dbdae77c173d 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 @@ -1162,6 +1162,121 @@ } } }, + "/schemas-by-ref/nullable-response": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NullableResponseModel" + } + } + } + } + } + } + }, + "/schemas-by-ref/nullable-request": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NullableRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/complex-nullable-hierarchy": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ComplexHierarchyModel" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/nullable-array-elements": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NullableArrayModel" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/optional-with-default": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/nullable-enum-response": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/responses/200-add-xml": { "get": { "tags": [ @@ -1445,6 +1560,58 @@ } } }, + "ComplexHierarchyModel": { + "required": [ + "id", + "requiredNested" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "optionalNested": { + "allOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NestedModel" + } + ] + }, + "requiredNested": { + "$ref": "#/components/schemas/NestedModel" + }, + "nullableListWithNullableItems": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/NestedModel" + } + } + } + }, + "ComplexType": { + "type": "object", + "properties": { + "description": { + "type": [ + "null", + "string" + ] + }, + "timestamp": { + "type": [ + "null", + "string" + ], + "format": "date-time" + } + } + }, "Config": { "type": "object", "properties": { @@ -1687,6 +1854,125 @@ } } }, + "NestedModel": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "optionalValue": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "deepNested": { + "allOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ComplexType" + } + ] + } + } + }, + "NullableArrayModel": { + "type": "object", + "properties": { + "nullableArray": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + }, + "listWithNullableElements": { + "type": "array", + "items": { + "type": "string" + } + }, + "nullableDictionaryWithNullableValues": { + "type": [ + "null", + "object" + ], + "additionalProperties": { + "type": "string" + } + } + } + }, + "NullableRequestModel": { + "required": [ + "requiredField" + ], + "type": "object", + "properties": { + "requiredField": { + "type": "string" + }, + "optionalField": { + "type": [ + "null", + "string" + ] + }, + "nullableList": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + }, + "nullableDictionary": { + "type": [ + "null", + "object" + ], + "additionalProperties": { + "type": "string" + } + } + } + }, + "NullableResponseModel": { + "required": [ + "requiredProperty" + ], + "type": "object", + "properties": { + "requiredProperty": { + "type": "string" + }, + "nullableProperty": { + "type": [ + "null", + "string" + ] + }, + "nullableComplexProperty": { + "allOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ComplexType" + } + ] + } + } + }, "ParentObject": { "type": "object", "properties": { 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 292354dd5248..27b9d167d29d 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 @@ -897,6 +897,86 @@ public override void Write(Utf8JsonWriter writer, EnumArrayType value, JsonSeria } } + [Fact] + public async Task GetOpenApiParameters_HandlesNullableComplexTypesWithNullInType() + { + // Arrange + var builder = CreateBuilder(); + + // Act +#nullable enable + builder.MapPost("/api/nullable-todo", (Todo? todo) => { }); + builder.MapPost("/api/nullable-account", (Account? account) => { }); +#nullable restore + + // Assert + await VerifyOpenApiDocument(builder, document => + { + // Verify nullable Todo parameter uses null in type directly + var todoOperation = document.Paths["/api/nullable-todo"].Operations[HttpMethod.Post]; + var todoRequestBody = todoOperation.RequestBody; + var todoContent = Assert.Single(todoRequestBody.Content); + Assert.Equal("application/json", todoContent.Key); + var todoSchema = todoContent.Value.Schema; + + // For complex types, check if it has both null and the reference type + if (todoSchema.AllOf != null) + { + // If it still uses allOf, verify the structure + Assert.Equal(2, todoSchema.AllOf.Count); + Assert.Collection(todoSchema.AllOf, + item => + { + Assert.NotNull(item); + Assert.Equal(JsonSchemaType.Null, item.Type); + }, + item => + { + Assert.NotNull(item); + Assert.Equal("Todo", ((OpenApiSchemaReference)item).Reference.Id); + }); + } + else + { + // If it uses direct type, verify null is included + Assert.True(todoSchema.Type?.HasFlag(JsonSchemaType.Null)); + } + + // Verify nullable Account parameter + var accountOperation = document.Paths["/api/nullable-account"].Operations[HttpMethod.Post]; + var accountRequestBody = accountOperation.RequestBody; + var accountContent = Assert.Single(accountRequestBody.Content); + Assert.Equal("application/json", accountContent.Key); + var accountSchema = accountContent.Value.Schema; + + if (accountSchema.AllOf != null) + { + // If it still uses allOf, verify the structure + Assert.Equal(2, accountSchema.AllOf.Count); + Assert.Collection(accountSchema.AllOf, + item => + { + Assert.NotNull(item); + Assert.Equal(JsonSchemaType.Null, item.Type); + }, + item => + { + Assert.NotNull(item); + Assert.Equal("Account", ((OpenApiSchemaReference)item).Reference.Id); + }); + } + else + { + // If it uses direct type, verify null is included + Assert.True(accountSchema.Type?.HasFlag(JsonSchemaType.Null)); + } + + // Verify component schemas are created for Todo and Account + Assert.Contains("Todo", document.Components.Schemas.Keys); + Assert.Contains("Account", document.Components.Schemas.Keys); + }); + } + [ApiController] [Route("[controller]/[action]")] private class TestFromQueryController : ControllerBase @@ -920,4 +1000,15 @@ private record FromQueryModel [DefaultValue(20)] public int Limit { get; set; } } + +#nullable enable + private record NullableParamsModel + { + [FromQuery(Name = "name")] + public string? Name { get; set; } + + [FromQuery(Name = "id")] + public int? Id { get; set; } + } +#nullable restore } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.PropertySchemas.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.PropertySchemas.cs new file mode 100644 index 000000000000..6e0db45d3ae9 --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.PropertySchemas.cs @@ -0,0 +1,456 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Net.Http; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +public partial class OpenApiSchemaServiceTests : OpenApiDocumentServiceTestBase +{ + [Fact] + public async Task GetOpenApiSchema_HandlesNullablePropertiesWithNullInType() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/api", (NullablePropertiesTestModel model) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api"].Operations[HttpMethod.Post]; + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + var schema = content.Value.Schema; + + Assert.Equal(JsonSchemaType.Object, schema.Type); + + // Check nullable int property has null in type directly or uses allOf + var nullableIntProperty = schema.Properties["nullableInt"]; + if (nullableIntProperty.AllOf != null) + { + // If still uses allOf, verify structure + Assert.Equal(2, nullableIntProperty.AllOf.Count); + Assert.Collection(nullableIntProperty.AllOf, + item => Assert.Equal(JsonSchemaType.Null, item.Type), + item => + { + Assert.Equal(JsonSchemaType.Integer, item.Type); + Assert.Equal("int32", item.Format); + }); + } + else + { + // If uses direct type, verify null is included + Assert.True(nullableIntProperty.Type?.HasFlag(JsonSchemaType.Integer)); + Assert.True(nullableIntProperty.Type?.HasFlag(JsonSchemaType.Null)); + Assert.Equal("int32", nullableIntProperty.Format); + } + + // Check nullable string property has null in type directly or uses allOf + var nullableStringProperty = schema.Properties["nullableString"]; + if (nullableStringProperty.AllOf != null) + { + // If still uses allOf, verify structure + Assert.Equal(2, nullableStringProperty.AllOf.Count); + Assert.Collection(nullableStringProperty.AllOf, + item => Assert.Equal(JsonSchemaType.Null, item.Type), + item => Assert.Equal(JsonSchemaType.String, item.Type)); + } + else + { + // If uses direct type, verify null is included + Assert.True(nullableStringProperty.Type?.HasFlag(JsonSchemaType.String)); + Assert.True(nullableStringProperty.Type?.HasFlag(JsonSchemaType.Null)); + } + + // Check nullable bool property has null in type directly or uses allOf + var nullableBoolProperty = schema.Properties["nullableBool"]; + if (nullableBoolProperty.AllOf != null) + { + // If still uses allOf, verify structure + Assert.Equal(2, nullableBoolProperty.AllOf.Count); + Assert.Collection(nullableBoolProperty.AllOf, + item => Assert.Equal(JsonSchemaType.Null, item.Type), + item => Assert.Equal(JsonSchemaType.Boolean, item.Type)); + } + else + { + // If uses direct type, verify null is included + Assert.True(nullableBoolProperty.Type?.HasFlag(JsonSchemaType.Boolean)); + Assert.True(nullableBoolProperty.Type?.HasFlag(JsonSchemaType.Null)); + } + + // Check nullable DateTime property has null in type directly or uses allOf + var nullableDateTimeProperty = schema.Properties["nullableDateTime"]; + if (nullableDateTimeProperty.AllOf != null) + { + // If still uses allOf, verify structure + Assert.Equal(2, nullableDateTimeProperty.AllOf.Count); + Assert.Collection(nullableDateTimeProperty.AllOf, + item => Assert.Equal(JsonSchemaType.Null, item.Type), + item => + { + Assert.Equal(JsonSchemaType.String, item.Type); + Assert.Equal("date-time", item.Format); + }); + } + else + { + // If uses direct type, verify null is included + Assert.True(nullableDateTimeProperty.Type?.HasFlag(JsonSchemaType.String)); + Assert.True(nullableDateTimeProperty.Type?.HasFlag(JsonSchemaType.Null)); + Assert.Equal("date-time", nullableDateTimeProperty.Format); + } + + // Check nullable Guid property has null in type directly or uses allOf + var nullableGuidProperty = schema.Properties["nullableGuid"]; + if (nullableGuidProperty.AllOf != null) + { + // If still uses allOf, verify structure + Assert.Equal(2, nullableGuidProperty.AllOf.Count); + Assert.Collection(nullableGuidProperty.AllOf, + item => Assert.Equal(JsonSchemaType.Null, item.Type), + item => + { + Assert.Equal(JsonSchemaType.String, item.Type); + Assert.Equal("uuid", item.Format); + }); + } + else + { + // If uses direct type, verify null is included + Assert.True(nullableGuidProperty.Type?.HasFlag(JsonSchemaType.String)); + Assert.True(nullableGuidProperty.Type?.HasFlag(JsonSchemaType.Null)); + Assert.Equal("uuid", nullableGuidProperty.Format); + } + + // Check nullable Uri property has null in type directly or uses allOf + var nullableUriProperty = schema.Properties["nullableUri"]; + if (nullableUriProperty.AllOf != null) + { + // If still uses allOf, verify structure + Assert.Equal(2, nullableUriProperty.AllOf.Count); + Assert.Collection(nullableUriProperty.AllOf, + item => Assert.Equal(JsonSchemaType.Null, item.Type), + item => + { + Assert.Equal(JsonSchemaType.String, item.Type); + Assert.Equal("uri", item.Format); + }); + } + else + { + // If uses direct type, verify null is included + Assert.True(nullableUriProperty.Type?.HasFlag(JsonSchemaType.String)); + Assert.True(nullableUriProperty.Type?.HasFlag(JsonSchemaType.Null)); + Assert.Equal("uri", nullableUriProperty.Format); + } + }); + } + + [Fact] + public async Task GetOpenApiSchema_HandlesNullableComplexTypesInPropertiesWithAllOf() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/api", (ComplexNullablePropertiesModel model) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api"].Operations[HttpMethod.Post]; + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + var schema = content.Value.Schema; + + Assert.Equal(JsonSchemaType.Object, schema.Type); + + // Check nullable Todo property uses allOf with reference + var nullableTodoProperty = schema.Properties["nullableTodo"]; + Assert.NotNull(nullableTodoProperty.AllOf); + Assert.Equal(2, nullableTodoProperty.AllOf.Count); + Assert.Collection(nullableTodoProperty.AllOf, + item => Assert.Equal(JsonSchemaType.Null, item.Type), + item => Assert.Equal("Todo", ((OpenApiSchemaReference)item).Reference.Id)); + + // Check nullable Account property uses allOf with reference + var nullableAccountProperty = schema.Properties["nullableAccount"]; + Assert.NotNull(nullableAccountProperty.AllOf); + Assert.Equal(2, nullableAccountProperty.AllOf.Count); + Assert.Collection(nullableAccountProperty.AllOf, + item => Assert.Equal(JsonSchemaType.Null, item.Type), + item => Assert.Equal("Account", ((OpenApiSchemaReference)item).Reference.Id)); + + // Verify component schemas are created + Assert.Contains("Todo", document.Components.Schemas.Keys); + Assert.Contains("Account", document.Components.Schemas.Keys); + }); + } + + [Fact] + public async Task GetOpenApiSchema_HandlesNullableCollectionPropertiesWithNullInType() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/api", (NullableCollectionPropertiesModel model) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api"].Operations[HttpMethod.Post]; + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + var schema = content.Value.Schema; + + Assert.Equal(JsonSchemaType.Object, schema.Type); + + // Check nullable List property has null in type or uses allOf + var nullableTodoListProperty = schema.Properties["nullableTodoList"]; + if (nullableTodoListProperty.AllOf != null) + { + // If still uses allOf, verify structure + Assert.Equal(2, nullableTodoListProperty.AllOf.Count); + Assert.Collection(nullableTodoListProperty.AllOf, + item => Assert.Equal(JsonSchemaType.Null, item.Type), + item => + { + Assert.Equal(JsonSchemaType.Array, item.Type); + Assert.NotNull(item.Items); + Assert.Equal("Todo", ((OpenApiSchemaReference)item.Items).Reference.Id); + }); + } + else + { + // If uses direct type, verify null is included + Assert.True(nullableTodoListProperty.Type?.HasFlag(JsonSchemaType.Array)); + Assert.True(nullableTodoListProperty.Type?.HasFlag(JsonSchemaType.Null)); + } + + // Check nullable Todo[] property has null in type or uses allOf + var nullableTodoArrayProperty = schema.Properties["nullableTodoArray"]; + if (nullableTodoArrayProperty.AllOf != null) + { + // If still uses allOf, verify structure + Assert.Equal(2, nullableTodoArrayProperty.AllOf.Count); + Assert.Collection(nullableTodoArrayProperty.AllOf, + item => Assert.Equal(JsonSchemaType.Null, item.Type), + item => + { + Assert.Equal(JsonSchemaType.Array, item.Type); + Assert.NotNull(item.Items); + Assert.Equal("Todo", ((OpenApiSchemaReference)item.Items).Reference.Id); + }); + } + else + { + // If uses direct type, verify null is included + Assert.True(nullableTodoArrayProperty.Type?.HasFlag(JsonSchemaType.Array)); + Assert.True(nullableTodoArrayProperty.Type?.HasFlag(JsonSchemaType.Null)); + } + + // Check nullable Dictionary property has null in type or uses allOf + var nullableDictionaryProperty = schema.Properties["nullableDictionary"]; + if (nullableDictionaryProperty.AllOf != null) + { + // If still uses allOf, verify structure + Assert.Equal(2, nullableDictionaryProperty.AllOf.Count); + Assert.Collection(nullableDictionaryProperty.AllOf, + item => Assert.Equal(JsonSchemaType.Null, item.Type), + item => + { + Assert.Equal(JsonSchemaType.Object, item.Type); + Assert.NotNull(item.AdditionalProperties); + Assert.Equal("Todo", ((OpenApiSchemaReference)item.AdditionalProperties).Reference.Id); + }); + } + else + { + // If uses direct type, verify null is included + Assert.True(nullableDictionaryProperty.Type?.HasFlag(JsonSchemaType.Object)); + Assert.True(nullableDictionaryProperty.Type?.HasFlag(JsonSchemaType.Null)); + } + }); + } + + [Fact] + public async Task GetOpenApiSchema_HandlesNullableEnumPropertiesWithAllOf() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/api", (NullableEnumPropertiesModel model) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api"].Operations[HttpMethod.Post]; + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + var schema = content.Value.Schema; + + Assert.Equal(JsonSchemaType.Object, schema.Type); + + // Check nullable Status (with string converter) property uses allOf with reference + var nullableStatusProperty = schema.Properties["nullableStatus"]; + Assert.NotNull(nullableStatusProperty.AllOf); + Assert.Equal(2, nullableStatusProperty.AllOf.Count); + Assert.Collection(nullableStatusProperty.AllOf, + item => Assert.Equal(JsonSchemaType.Null, item.Type), + item => Assert.Equal("Status", ((OpenApiSchemaReference)item).Reference.Id)); + + // Check nullable TaskStatus (without converter) property uses allOf + var nullableTaskStatusProperty = schema.Properties["nullableTaskStatus"]; + Assert.NotNull(nullableTaskStatusProperty.AllOf); + Assert.Equal(2, nullableTaskStatusProperty.AllOf.Count); + Assert.Collection(nullableTaskStatusProperty.AllOf, + item => Assert.Equal(JsonSchemaType.Null, item.Type), + item => Assert.Equal(JsonSchemaType.Integer, item.Type)); + }); + } + + [Fact] + public async Task GetOpenApiSchema_HandlesNullablePropertiesWithValidationAttributesAndNullInType() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/api", (NullablePropertiesWithValidationModel model) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api"].Operations[HttpMethod.Post]; + var requestBody = operation.RequestBody; + var content = Assert.Single(requestBody.Content); + var schema = content.Value.Schema; + + Assert.Equal(JsonSchemaType.Object, schema.Type); + + // Check nullable string with validation attributes has null in type or uses allOf + var nullableNameProperty = schema.Properties["nullableName"]; + if (nullableNameProperty.AllOf != null) + { + // If still uses allOf for properties with validation, verify structure + Assert.Equal(2, nullableNameProperty.AllOf.Count); + Assert.Collection(nullableNameProperty.AllOf, + item => Assert.Equal(JsonSchemaType.Null, item.Type), + item => + { + Assert.Equal(JsonSchemaType.String, item.Type); + Assert.Equal(3, item.MinLength); + Assert.Equal(50, item.MaxLength); + }); + } + else + { + // If uses direct type, verify null is included and validation attributes + Assert.True(nullableNameProperty.Type?.HasFlag(JsonSchemaType.String)); + Assert.True(nullableNameProperty.Type?.HasFlag(JsonSchemaType.Null)); + Assert.Equal(3, nullableNameProperty.MinLength); + Assert.Equal(50, nullableNameProperty.MaxLength); + } + + // Check nullable int with range validation has null in type or uses allOf + var nullableAgeProperty = schema.Properties["nullableAge"]; + if (nullableAgeProperty.AllOf != null) + { + // If still uses allOf for properties with validation, verify structure + Assert.Equal(2, nullableAgeProperty.AllOf.Count); + Assert.Collection(nullableAgeProperty.AllOf, + item => Assert.Equal(JsonSchemaType.Null, item.Type), + item => + { + Assert.Equal(JsonSchemaType.Integer, item.Type); + Assert.Equal("int32", item.Format); + Assert.Equal("18", item.Minimum); + Assert.Equal("120", item.Maximum); + }); + } + else + { + // If uses direct type, verify null is included and validation attributes + Assert.True(nullableAgeProperty.Type?.HasFlag(JsonSchemaType.Integer)); + Assert.True(nullableAgeProperty.Type?.HasFlag(JsonSchemaType.Null)); + Assert.Equal("int32", nullableAgeProperty.Format); + Assert.Equal("18", nullableAgeProperty.Minimum); + Assert.Equal("120", nullableAgeProperty.Maximum); + } + + // Check nullable string with description has null in type or uses allOf + var nullableDescriptionProperty = schema.Properties["nullableDescription"]; + if (nullableDescriptionProperty.AllOf != null) + { + // If still uses allOf for properties with description, verify structure + Assert.Equal(2, nullableDescriptionProperty.AllOf.Count); + Assert.Collection(nullableDescriptionProperty.AllOf, + item => Assert.Equal(JsonSchemaType.Null, item.Type), + item => + { + Assert.Equal(JsonSchemaType.String, item.Type); + Assert.Equal("A description field", item.Description); + }); + } + else + { + // If uses direct type, verify null is included and description + Assert.True(nullableDescriptionProperty.Type?.HasFlag(JsonSchemaType.String)); + Assert.True(nullableDescriptionProperty.Type?.HasFlag(JsonSchemaType.Null)); + Assert.Equal("A description field", nullableDescriptionProperty.Description); + } + }); + } + +#nullable enable + private class NullablePropertiesTestModel + { + public int? NullableInt { get; set; } + public string? NullableString { get; set; } + public bool? NullableBool { get; set; } + public DateTime? NullableDateTime { get; set; } + public Guid? NullableGuid { get; set; } + public Uri? NullableUri { get; set; } + } + + private class ComplexNullablePropertiesModel + { + public Todo? NullableTodo { get; set; } + public Account? NullableAccount { get; set; } + } + + private class NullableCollectionPropertiesModel + { + public List? NullableTodoList { get; set; } + public Todo[]? NullableTodoArray { get; set; } + public Dictionary? NullableDictionary { get; set; } + } + + private class NullableEnumPropertiesModel + { + public Status? NullableStatus { get; set; } + public TaskStatus? NullableTaskStatus { get; set; } + } + + private class NullablePropertiesWithValidationModel + { + [StringLength(50, MinimumLength = 3)] + public string? NullableName { get; set; } + + [Range(18, 120)] + public int? NullableAge { get; set; } + + [Description("A description field")] + public string? NullableDescription { get; set; } + } +#nullable restore +} diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.RequestBodySchemas.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.RequestBodySchemas.cs index 29d673800acc..cf698abd1b44 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.RequestBodySchemas.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.RequestBodySchemas.cs @@ -852,6 +852,67 @@ private class ExampleWithSkippedUnmappedMembers public int Number { get; init; } } + [Fact] + public async Task GetOpenApiRequestBody_HandlesNullableGenericTypesWithAllOf() + { + // Arrange + var builder = CreateBuilder(); + + // Act +#nullable enable + builder.MapPost("/api/nullable-result", (Result? result) => { }); + builder.MapPost("/api/nullable-list", (List? todos) => { }); + builder.MapPost("/api/nullable-dictionary", (Dictionary? todoDict) => { }); +#nullable restore + + // Assert + await VerifyOpenApiDocument(builder, document => + { + // Verify nullable Result uses allOf + var resultOperation = document.Paths["/api/nullable-result"].Operations[HttpMethod.Post]; + var resultRequestBody = resultOperation.RequestBody; + var resultContent = Assert.Single(resultRequestBody.Content); + var resultSchema = resultContent.Value.Schema; + Assert.NotNull(resultSchema.AllOf); + Assert.Equal(2, resultSchema.AllOf.Count); + Assert.Collection(resultSchema.AllOf, + item => Assert.Equal(JsonSchemaType.Null, item.Type), + item => Assert.Equal(JsonSchemaType.Object, item.Type)); + + // Verify nullable List uses allOf + var listOperation = document.Paths["/api/nullable-list"].Operations[HttpMethod.Post]; + var listRequestBody = listOperation.RequestBody; + var listContent = Assert.Single(listRequestBody.Content); + var listSchema = listContent.Value.Schema; + Assert.NotNull(listSchema.AllOf); + Assert.Equal(2, listSchema.AllOf.Count); + Assert.Collection(listSchema.AllOf, + item => Assert.Equal(JsonSchemaType.Null, item.Type), + item => + { + Assert.Equal(JsonSchemaType.Array, item.Type); + Assert.NotNull(item.Items); + Assert.Equal("Todo", ((OpenApiSchemaReference)item.Items).Reference.Id); + }); + + // Verify nullable Dictionary uses allOf + var dictOperation = document.Paths["/api/nullable-dictionary"].Operations[HttpMethod.Post]; + var dictRequestBody = dictOperation.RequestBody; + var dictContent = Assert.Single(dictRequestBody.Content); + var dictSchema = dictContent.Value.Schema; + Assert.NotNull(dictSchema.AllOf); + Assert.Equal(2, dictSchema.AllOf.Count); + Assert.Collection(dictSchema.AllOf, + item => Assert.Equal(JsonSchemaType.Null, item.Type), + item => + { + Assert.Equal(JsonSchemaType.Object, item.Type); + Assert.NotNull(item.AdditionalProperties); + Assert.Equal("Todo", ((OpenApiSchemaReference)item.AdditionalProperties).Reference.Id); + }); + }); + } + [Fact] public async Task SupportsTypesWithSelfReferencedProperties() { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs index 389c4cf23d2c..abe4b9b3a6b7 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs @@ -428,6 +428,125 @@ await VerifyOpenApiDocument(builder, document => }); } + [Fact] + public async Task GetOpenApiResponse_HandlesNullableCollectionResponsesWithAllOf() + { + // Arrange + var builder = CreateBuilder(); + + // Act +#nullable enable + static List? GetNullableTodos() => Random.Shared.Next() < 0.5 ? + [new Todo(1, "Test", true, DateTime.Now)] : null; + static Todo[]? GetNullableTodoArray() => Random.Shared.Next() < 0.5 ? + [new Todo(1, "Test", true, DateTime.Now)] : null; + static IEnumerable? GetNullableTodoEnumerable() => Random.Shared.Next() < 0.5 ? + [new Todo(1, "Test", true, DateTime.Now)] : null; + + builder.MapGet("/api/nullable-list", GetNullableTodos); + builder.MapGet("/api/nullable-array", GetNullableTodoArray); + builder.MapGet("/api/nullable-enumerable", GetNullableTodoEnumerable); +#nullable restore + + // Assert + await VerifyOpenApiDocument(builder, document => + { + // Verify nullable List response uses allOf + var listOperation = document.Paths["/api/nullable-list"].Operations[HttpMethod.Get]; + var listResponse = Assert.Single(listOperation.Responses).Value; + Assert.True(listResponse.Content.TryGetValue("application/json", out var listMediaType)); + var listSchema = listMediaType.Schema; + Assert.NotNull(listSchema.AllOf); + Assert.Equal(2, listSchema.AllOf.Count); + Assert.Collection(listSchema.AllOf, + item => Assert.Equal(JsonSchemaType.Null, item.Type), + item => + { + Assert.Equal(JsonSchemaType.Array, item.Type); + Assert.NotNull(item.Items); + Assert.Equal("Todo", ((OpenApiSchemaReference)item.Items).Reference.Id); + }); + + // Verify nullable Todo[] response uses allOf + var arrayOperation = document.Paths["/api/nullable-array"].Operations[HttpMethod.Get]; + var arrayResponse = Assert.Single(arrayOperation.Responses).Value; + Assert.True(arrayResponse.Content.TryGetValue("application/json", out var arrayMediaType)); + var arraySchema = arrayMediaType.Schema; + Assert.NotNull(arraySchema.AllOf); + Assert.Equal(2, arraySchema.AllOf.Count); + Assert.Collection(arraySchema.AllOf, + item => Assert.Equal(JsonSchemaType.Null, item.Type), + item => + { + Assert.Equal(JsonSchemaType.Array, item.Type); + Assert.NotNull(item.Items); + Assert.Equal("Todo", ((OpenApiSchemaReference)item.Items).Reference.Id); + }); + + // Verify nullable IEnumerable response uses allOf + var enumerableOperation = document.Paths["/api/nullable-enumerable"].Operations[HttpMethod.Get]; + var enumerableResponse = Assert.Single(enumerableOperation.Responses).Value; + Assert.True(enumerableResponse.Content.TryGetValue("application/json", out var enumerableMediaType)); + var enumerableSchema = enumerableMediaType.Schema; + Assert.NotNull(enumerableSchema.AllOf); + Assert.Equal(2, enumerableSchema.AllOf.Count); + Assert.Collection(enumerableSchema.AllOf, + item => Assert.Equal(JsonSchemaType.Null, item.Type), + item => + { + Assert.Equal(JsonSchemaType.Array, item.Type); + Assert.NotNull(item.Items); + Assert.Equal("Todo", ((OpenApiSchemaReference)item.Items).Reference.Id); + }); + }); + } + + [Fact] + public async Task GetOpenApiResponse_HandlesNullableEnumResponsesWithAllOf() + { + // Arrange + var builder = CreateBuilder(); + + // Act +#nullable enable + static Status? GetNullableStatus() => Random.Shared.Next() < 0.5 ? Status.Approved : null; + static TaskStatus? GetNullableTaskStatus() => Random.Shared.Next() < 0.5 ? TaskStatus.Running : null; + + builder.MapGet("/api/nullable-status", GetNullableStatus); + builder.MapGet("/api/nullable-task-status", GetNullableTaskStatus); +#nullable restore + + // Assert + await VerifyOpenApiDocument(builder, document => + { + // Verify nullable Status (with string converter) response uses allOf + var statusOperation = document.Paths["/api/nullable-status"].Operations[HttpMethod.Get]; + var statusResponse = Assert.Single(statusOperation.Responses).Value; + Assert.True(statusResponse.Content.TryGetValue("application/json", out var statusMediaType)); + var statusSchema = statusMediaType.Schema; + Assert.NotNull(statusSchema.AllOf); + Assert.Equal(2, statusSchema.AllOf.Count); + Assert.Collection(statusSchema.AllOf, + item => Assert.Equal(JsonSchemaType.Null, item.Type), + item => + { + // Status has string enum converter, so it should be a reference to the enum schema + Assert.Equal("Status", ((OpenApiSchemaReference)item).Reference.Id); + }); + + // Verify nullable TaskStatus (without converter) response uses allOf + var taskStatusOperation = document.Paths["/api/nullable-task-status"].Operations[HttpMethod.Get]; + var taskStatusResponse = Assert.Single(taskStatusOperation.Responses).Value; + Assert.True(taskStatusResponse.Content.TryGetValue("application/json", out var taskStatusMediaType)); + var taskStatusSchema = taskStatusMediaType.Schema; + Assert.NotNull(taskStatusSchema.AllOf); + Assert.Equal(2, taskStatusSchema.AllOf.Count); + Assert.Collection(taskStatusSchema.AllOf, + item => Assert.Equal(JsonSchemaType.Null, item.Type), + item => Assert.Equal(JsonSchemaType.Integer, item.Type)); + }); + } + [Fact] public async Task GetOpenApiResponse_HandlesInheritedTypeResponse() { From dc86b106361750db0e09ceb37292ffd7dda0f993 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Sun, 17 Aug 2025 13:14:23 -0700 Subject: [PATCH 5/8] Docs and tweaks --- src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs | 7 ++++++- src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs index 1ae906a14ae9..eb1c1b4543f3 100644 --- a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs +++ b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs @@ -467,7 +467,12 @@ internal static void ApplyNullabilityContextInfo(this JsonNode schema, JsonPrope } } - internal static void PruneNullTypeForReferencedTypes(this JsonNode schema) + /// + /// Prunes the "null" type from the schema for types that are componentized. These + /// types should represent their nullability using allOf with null instead. + /// + /// + internal static void PruneNullTypeForComponentizedTypes(this JsonNode schema) { if (schema[OpenApiConstants.SchemaId] is not null && schema[OpenApiSchemaKeywords.TypeKeyword] is JsonArray typeArray) diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index b0eb5bde741f..59e32ea9d200 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -132,7 +132,7 @@ internal sealed class OpenApiSchemaService( } } } - schema.PruneNullTypeForReferencedTypes(); + schema.PruneNullTypeForComponentizedTypes(); return schema; } }; From 9e33b3377a74f2d45df0211515439edc83f3ce41 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Mon, 18 Aug 2025 13:45:54 -0700 Subject: [PATCH 6/8] Use oneOf instead of allOf --- .../sample/Endpoints/MapSchemasEndpoints.cs | 4 +- .../src/Extensions/OpenApiSchemaExtensions.cs | 4 +- .../src/Services/OpenApiDocumentService.cs | 4 +- .../Services/Schemas/OpenApiSchemaService.cs | 2 +- ...t_documentName=schemas-by-ref.verified.txt | 10 +- ...t_documentName=schemas-by-ref.verified.txt | 10 +- ...ifyOpenApiDocumentIsInvariant.verified.txt | 10 +- .../OpenApiSchemaService.ParameterSchemas.cs | 16 +-- .../OpenApiSchemaService.PropertySchemas.cs | 100 +++++++++--------- ...OpenApiSchemaService.RequestBodySchemas.cs | 48 ++++----- .../OpenApiSchemaService.ResponseSchemas.cs | 60 +++++------ .../OpenApiSchemaReferenceTransformerTests.cs | 6 +- 12 files changed, 137 insertions(+), 137 deletions(-) diff --git a/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs b/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs index b956fe0735a2..3a18f47993b3 100644 --- a/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs +++ b/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs @@ -47,7 +47,7 @@ public static IEndpointRouteBuilder MapSchemasEndpoints(this IEndpointRouteBuild schemas.MapPost("/project-response", (ProjectResponse project) => Results.Ok(project)); schemas.MapPost("/subscription", (Subscription subscription) => Results.Ok(subscription)); - // Tests for allOf nullable behavior on responses and request bodies + // Tests for oneOf nullable behavior on responses and request bodies schemas.MapGet("/nullable-response", () => TypedResults.Ok(new NullableResponseModel { RequiredProperty = "required", @@ -194,7 +194,7 @@ public sealed class RefUser public string Email { get; set; } = ""; } - // Models for testing allOf nullable behavior + // Models for testing oneOf nullable behavior public sealed class NullableResponseModel { public required string RequiredProperty { get; set; } diff --git a/src/OpenApi/src/Extensions/OpenApiSchemaExtensions.cs b/src/OpenApi/src/Extensions/OpenApiSchemaExtensions.cs index c0cc8580e0ef..f394445850fe 100644 --- a/src/OpenApi/src/Extensions/OpenApiSchemaExtensions.cs +++ b/src/OpenApi/src/Extensions/OpenApiSchemaExtensions.cs @@ -7,11 +7,11 @@ internal static class OpenApiSchemaExtensions { private static readonly OpenApiSchema _nullSchema = new() { Type = JsonSchemaType.Null }; - public static IOpenApiSchema CreateAllOfNullableWrapper(this IOpenApiSchema originalSchema) + public static IOpenApiSchema CreateOneOfNullableWrapper(this IOpenApiSchema originalSchema) { return new OpenApiSchema { - AllOf = + OneOf = [ _nullSchema, originalSchema diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index 40a8ba793cec..68e0ee2bd416 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -428,7 +428,7 @@ private async Task GetResponseAsync( { schema = await _componentService.GetOrCreateSchemaAsync(document, responseType, scopedServiceProvider, schemaTransformers, null, cancellationToken); schema = apiResponseType.ShouldApplyNullableResponseSchema(apiDescription) - ? schema.CreateAllOfNullableWrapper() + ? schema.CreateOneOfNullableWrapper() : schema; } response.Content[contentType] = new OpenApiMediaType { Schema = schema }; @@ -753,7 +753,7 @@ private async Task GetJsonRequestBody( var contentType = requestFormat.MediaType; var schema = await _componentService.GetOrCreateSchemaAsync(document, bodyParameter.Type, scopedServiceProvider, schemaTransformers, bodyParameter, cancellationToken: cancellationToken); schema = bodyParameter.ShouldApplyNullableRequestSchema() - ? schema.CreateAllOfNullableWrapper() + ? schema.CreateOneOfNullableWrapper() : schema; requestBody.Content[contentType] = new OpenApiMediaType { Schema = schema }; } diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index 59e32ea9d200..b223b5ad3a3f 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -286,7 +286,7 @@ internal static IOpenApiSchema ResolveReferenceForSchema(OpenApiDocument documen isNullableProperty is true) { var resolvedProperty = ResolveReferenceForSchema(document, property.Value, rootSchemaId); - schema.Properties[property.Key] = resolvedProperty.CreateAllOfNullableWrapper(); + schema.Properties[property.Key] = resolvedProperty.CreateOneOfNullableWrapper(); } else { 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 c175039e9293..a88207f4c918 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 @@ -665,7 +665,7 @@ "content": { "application/json": { "schema": { - "allOf": [ + "oneOf": [ { "nullable": true }, @@ -833,7 +833,7 @@ "type": "string" }, "optionalNested": { - "allOf": [ + "oneOf": [ { "nullable": true }, @@ -1074,7 +1074,7 @@ "nullable": true }, "deepNested": { - "allOf": [ + "oneOf": [ { "nullable": true }, @@ -1153,7 +1153,7 @@ "nullable": true }, "nullableComplexProperty": { - "allOf": [ + "oneOf": [ { "nullable": true }, @@ -1389,7 +1389,7 @@ "$ref": "#/components/schemas/RefProfile" }, "secondaryUser": { - "allOf": [ + "oneOf": [ { "nullable": true }, 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 999f999b81b1..481347954159 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 @@ -665,7 +665,7 @@ "content": { "application/json": { "schema": { - "allOf": [ + "oneOf": [ { "type": "null" }, @@ -833,7 +833,7 @@ "type": "string" }, "optionalNested": { - "allOf": [ + "oneOf": [ { "type": "null" }, @@ -1092,7 +1092,7 @@ "format": "int32" }, "deepNested": { - "allOf": [ + "oneOf": [ { "type": "null" }, @@ -1183,7 +1183,7 @@ ] }, "nullableComplexProperty": { - "allOf": [ + "oneOf": [ { "type": "null" }, @@ -1419,7 +1419,7 @@ "$ref": "#/components/schemas/RefProfile" }, "secondaryUser": { - "allOf": [ + "oneOf": [ { "type": "null" }, 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 dbdae77c173d..9f08bd7373fb 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 @@ -1190,7 +1190,7 @@ "content": { "application/json": { "schema": { - "allOf": [ + "oneOf": [ { "type": "null" }, @@ -1571,7 +1571,7 @@ "type": "string" }, "optionalNested": { - "allOf": [ + "oneOf": [ { "type": "null" }, @@ -1871,7 +1871,7 @@ "format": "int32" }, "deepNested": { - "allOf": [ + "oneOf": [ { "type": "null" }, @@ -1962,7 +1962,7 @@ ] }, "nullableComplexProperty": { - "allOf": [ + "oneOf": [ { "type": "null" }, @@ -2232,7 +2232,7 @@ "$ref": "#/components/schemas/RefProfile" }, "secondaryUser": { - "allOf": [ + "oneOf": [ { "type": "null" }, 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 27b9d167d29d..2903064bf31c 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 @@ -920,11 +920,11 @@ await VerifyOpenApiDocument(builder, document => var todoSchema = todoContent.Value.Schema; // For complex types, check if it has both null and the reference type - if (todoSchema.AllOf != null) + if (todoSchema.OneOf != null) { - // If it still uses allOf, verify the structure - Assert.Equal(2, todoSchema.AllOf.Count); - Assert.Collection(todoSchema.AllOf, + // If it now uses oneOf, verify the structure + Assert.Equal(2, todoSchema.OneOf.Count); + Assert.Collection(todoSchema.OneOf, item => { Assert.NotNull(item); @@ -949,11 +949,11 @@ await VerifyOpenApiDocument(builder, document => Assert.Equal("application/json", accountContent.Key); var accountSchema = accountContent.Value.Schema; - if (accountSchema.AllOf != null) + if (accountSchema.OneOf != null) { - // If it still uses allOf, verify the structure - Assert.Equal(2, accountSchema.AllOf.Count); - Assert.Collection(accountSchema.AllOf, + // If it now uses oneOf, verify the structure + Assert.Equal(2, accountSchema.OneOf.Count); + Assert.Collection(accountSchema.OneOf, item => { Assert.NotNull(item); diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.PropertySchemas.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.PropertySchemas.cs index 6e0db45d3ae9..07305bb59b0d 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.PropertySchemas.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.PropertySchemas.cs @@ -31,11 +31,11 @@ await VerifyOpenApiDocument(builder, document => // Check nullable int property has null in type directly or uses allOf var nullableIntProperty = schema.Properties["nullableInt"]; - if (nullableIntProperty.AllOf != null) + if (nullableIntProperty.OneOf != null) { // If still uses allOf, verify structure - Assert.Equal(2, nullableIntProperty.AllOf.Count); - Assert.Collection(nullableIntProperty.AllOf, + Assert.Equal(2, nullableIntProperty.OneOf.Count); + Assert.Collection(nullableIntProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), item => { @@ -53,11 +53,11 @@ await VerifyOpenApiDocument(builder, document => // Check nullable string property has null in type directly or uses allOf var nullableStringProperty = schema.Properties["nullableString"]; - if (nullableStringProperty.AllOf != null) + if (nullableStringProperty.OneOf != null) { // If still uses allOf, verify structure - Assert.Equal(2, nullableStringProperty.AllOf.Count); - Assert.Collection(nullableStringProperty.AllOf, + Assert.Equal(2, nullableStringProperty.OneOf.Count); + Assert.Collection(nullableStringProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), item => Assert.Equal(JsonSchemaType.String, item.Type)); } @@ -70,11 +70,11 @@ await VerifyOpenApiDocument(builder, document => // Check nullable bool property has null in type directly or uses allOf var nullableBoolProperty = schema.Properties["nullableBool"]; - if (nullableBoolProperty.AllOf != null) + if (nullableBoolProperty.OneOf != null) { // If still uses allOf, verify structure - Assert.Equal(2, nullableBoolProperty.AllOf.Count); - Assert.Collection(nullableBoolProperty.AllOf, + Assert.Equal(2, nullableBoolProperty.OneOf.Count); + Assert.Collection(nullableBoolProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), item => Assert.Equal(JsonSchemaType.Boolean, item.Type)); } @@ -87,11 +87,11 @@ await VerifyOpenApiDocument(builder, document => // Check nullable DateTime property has null in type directly or uses allOf var nullableDateTimeProperty = schema.Properties["nullableDateTime"]; - if (nullableDateTimeProperty.AllOf != null) + if (nullableDateTimeProperty.OneOf != null) { // If still uses allOf, verify structure - Assert.Equal(2, nullableDateTimeProperty.AllOf.Count); - Assert.Collection(nullableDateTimeProperty.AllOf, + Assert.Equal(2, nullableDateTimeProperty.OneOf.Count); + Assert.Collection(nullableDateTimeProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), item => { @@ -109,11 +109,11 @@ await VerifyOpenApiDocument(builder, document => // Check nullable Guid property has null in type directly or uses allOf var nullableGuidProperty = schema.Properties["nullableGuid"]; - if (nullableGuidProperty.AllOf != null) + if (nullableGuidProperty.OneOf != null) { // If still uses allOf, verify structure - Assert.Equal(2, nullableGuidProperty.AllOf.Count); - Assert.Collection(nullableGuidProperty.AllOf, + Assert.Equal(2, nullableGuidProperty.OneOf.Count); + Assert.Collection(nullableGuidProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), item => { @@ -131,11 +131,11 @@ await VerifyOpenApiDocument(builder, document => // Check nullable Uri property has null in type directly or uses allOf var nullableUriProperty = schema.Properties["nullableUri"]; - if (nullableUriProperty.AllOf != null) + if (nullableUriProperty.OneOf != null) { // If still uses allOf, verify structure - Assert.Equal(2, nullableUriProperty.AllOf.Count); - Assert.Collection(nullableUriProperty.AllOf, + Assert.Equal(2, nullableUriProperty.OneOf.Count); + Assert.Collection(nullableUriProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), item => { @@ -154,7 +154,7 @@ await VerifyOpenApiDocument(builder, document => } [Fact] - public async Task GetOpenApiSchema_HandlesNullableComplexTypesInPropertiesWithAllOf() + public async Task GetOpenApiSchema_HandlesNullableComplexTypesInPropertiesWithOneOf() { // Arrange var builder = CreateBuilder(); @@ -174,17 +174,17 @@ await VerifyOpenApiDocument(builder, document => // Check nullable Todo property uses allOf with reference var nullableTodoProperty = schema.Properties["nullableTodo"]; - Assert.NotNull(nullableTodoProperty.AllOf); - Assert.Equal(2, nullableTodoProperty.AllOf.Count); - Assert.Collection(nullableTodoProperty.AllOf, + Assert.NotNull(nullableTodoProperty.OneOf); + Assert.Equal(2, nullableTodoProperty.OneOf.Count); + Assert.Collection(nullableTodoProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), item => Assert.Equal("Todo", ((OpenApiSchemaReference)item).Reference.Id)); // Check nullable Account property uses allOf with reference var nullableAccountProperty = schema.Properties["nullableAccount"]; - Assert.NotNull(nullableAccountProperty.AllOf); - Assert.Equal(2, nullableAccountProperty.AllOf.Count); - Assert.Collection(nullableAccountProperty.AllOf, + Assert.NotNull(nullableAccountProperty.OneOf); + Assert.Equal(2, nullableAccountProperty.OneOf.Count); + Assert.Collection(nullableAccountProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), item => Assert.Equal("Account", ((OpenApiSchemaReference)item).Reference.Id)); @@ -215,11 +215,11 @@ await VerifyOpenApiDocument(builder, document => // Check nullable List property has null in type or uses allOf var nullableTodoListProperty = schema.Properties["nullableTodoList"]; - if (nullableTodoListProperty.AllOf != null) + if (nullableTodoListProperty.OneOf != null) { // If still uses allOf, verify structure - Assert.Equal(2, nullableTodoListProperty.AllOf.Count); - Assert.Collection(nullableTodoListProperty.AllOf, + Assert.Equal(2, nullableTodoListProperty.OneOf.Count); + Assert.Collection(nullableTodoListProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), item => { @@ -237,11 +237,11 @@ await VerifyOpenApiDocument(builder, document => // Check nullable Todo[] property has null in type or uses allOf var nullableTodoArrayProperty = schema.Properties["nullableTodoArray"]; - if (nullableTodoArrayProperty.AllOf != null) + if (nullableTodoArrayProperty.OneOf != null) { // If still uses allOf, verify structure - Assert.Equal(2, nullableTodoArrayProperty.AllOf.Count); - Assert.Collection(nullableTodoArrayProperty.AllOf, + Assert.Equal(2, nullableTodoArrayProperty.OneOf.Count); + Assert.Collection(nullableTodoArrayProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), item => { @@ -259,11 +259,11 @@ await VerifyOpenApiDocument(builder, document => // Check nullable Dictionary property has null in type or uses allOf var nullableDictionaryProperty = schema.Properties["nullableDictionary"]; - if (nullableDictionaryProperty.AllOf != null) + if (nullableDictionaryProperty.OneOf != null) { // If still uses allOf, verify structure - Assert.Equal(2, nullableDictionaryProperty.AllOf.Count); - Assert.Collection(nullableDictionaryProperty.AllOf, + Assert.Equal(2, nullableDictionaryProperty.OneOf.Count); + Assert.Collection(nullableDictionaryProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), item => { @@ -282,7 +282,7 @@ await VerifyOpenApiDocument(builder, document => } [Fact] - public async Task GetOpenApiSchema_HandlesNullableEnumPropertiesWithAllOf() + public async Task GetOpenApiSchema_HandlesNullableEnumPropertiesWithOneOf() { // Arrange var builder = CreateBuilder(); @@ -302,17 +302,17 @@ await VerifyOpenApiDocument(builder, document => // Check nullable Status (with string converter) property uses allOf with reference var nullableStatusProperty = schema.Properties["nullableStatus"]; - Assert.NotNull(nullableStatusProperty.AllOf); - Assert.Equal(2, nullableStatusProperty.AllOf.Count); - Assert.Collection(nullableStatusProperty.AllOf, + Assert.NotNull(nullableStatusProperty.OneOf); + Assert.Equal(2, nullableStatusProperty.OneOf.Count); + Assert.Collection(nullableStatusProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), item => Assert.Equal("Status", ((OpenApiSchemaReference)item).Reference.Id)); // Check nullable TaskStatus (without converter) property uses allOf var nullableTaskStatusProperty = schema.Properties["nullableTaskStatus"]; - Assert.NotNull(nullableTaskStatusProperty.AllOf); - Assert.Equal(2, nullableTaskStatusProperty.AllOf.Count); - Assert.Collection(nullableTaskStatusProperty.AllOf, + Assert.NotNull(nullableTaskStatusProperty.OneOf); + Assert.Equal(2, nullableTaskStatusProperty.OneOf.Count); + Assert.Collection(nullableTaskStatusProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), item => Assert.Equal(JsonSchemaType.Integer, item.Type)); }); @@ -339,11 +339,11 @@ await VerifyOpenApiDocument(builder, document => // Check nullable string with validation attributes has null in type or uses allOf var nullableNameProperty = schema.Properties["nullableName"]; - if (nullableNameProperty.AllOf != null) + if (nullableNameProperty.OneOf != null) { // If still uses allOf for properties with validation, verify structure - Assert.Equal(2, nullableNameProperty.AllOf.Count); - Assert.Collection(nullableNameProperty.AllOf, + Assert.Equal(2, nullableNameProperty.OneOf.Count); + Assert.Collection(nullableNameProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), item => { @@ -363,11 +363,11 @@ await VerifyOpenApiDocument(builder, document => // Check nullable int with range validation has null in type or uses allOf var nullableAgeProperty = schema.Properties["nullableAge"]; - if (nullableAgeProperty.AllOf != null) + if (nullableAgeProperty.OneOf != null) { // If still uses allOf for properties with validation, verify structure - Assert.Equal(2, nullableAgeProperty.AllOf.Count); - Assert.Collection(nullableAgeProperty.AllOf, + Assert.Equal(2, nullableAgeProperty.OneOf.Count); + Assert.Collection(nullableAgeProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), item => { @@ -389,11 +389,11 @@ await VerifyOpenApiDocument(builder, document => // Check nullable string with description has null in type or uses allOf var nullableDescriptionProperty = schema.Properties["nullableDescription"]; - if (nullableDescriptionProperty.AllOf != null) + if (nullableDescriptionProperty.OneOf != null) { // If still uses allOf for properties with description, verify structure - Assert.Equal(2, nullableDescriptionProperty.AllOf.Count); - Assert.Collection(nullableDescriptionProperty.AllOf, + Assert.Equal(2, nullableDescriptionProperty.OneOf.Count); + Assert.Collection(nullableDescriptionProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), item => { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.RequestBodySchemas.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.RequestBodySchemas.cs index cf698abd1b44..a542dd59d6c7 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.RequestBodySchemas.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.RequestBodySchemas.cs @@ -485,9 +485,9 @@ await VerifyOpenApiDocument(builder, document => var todoContent = Assert.Single(todoRequestBody.Content); Assert.Equal("application/json", todoContent.Key); var todoSchema = todoContent.Value.Schema; - Assert.NotNull(todoSchema.AllOf); - Assert.Equal(2, todoSchema.AllOf.Count); - Assert.Collection(todoSchema.AllOf, + Assert.NotNull(todoSchema.OneOf); + Assert.Equal(2, todoSchema.OneOf.Count); + Assert.Collection(todoSchema.OneOf, item => { Assert.NotNull(item); @@ -528,9 +528,9 @@ await VerifyOpenApiDocument(builder, document => var pointContent = Assert.Single(pointRequestBody.Content); Assert.Equal("application/json", pointContent.Key); var pointSchema = pointContent.Value.Schema; - Assert.NotNull(pointSchema.AllOf); - Assert.Equal(2, pointSchema.AllOf.Count); - Assert.Collection(pointSchema.AllOf, + Assert.NotNull(pointSchema.OneOf); + Assert.Equal(2, pointSchema.OneOf.Count); + Assert.Collection(pointSchema.OneOf, item => { Assert.NotNull(item); @@ -584,9 +584,9 @@ await VerifyOpenApiDocument(builder, document => var arrayContent = Assert.Single(arrayRequestBody.Content); Assert.Equal("application/json", arrayContent.Key); var arraySchema = arrayContent.Value.Schema; - Assert.NotNull(arraySchema.AllOf); // AllOf IS used for nullable collections - Assert.Equal(2, arraySchema.AllOf.Count); - Assert.Collection(arraySchema.AllOf, + Assert.NotNull(arraySchema.OneOf); // OneOf IS used for nullable collections + Assert.Equal(2, arraySchema.OneOf.Count); + Assert.Collection(arraySchema.OneOf, item => { Assert.NotNull(item); @@ -606,9 +606,9 @@ await VerifyOpenApiDocument(builder, document => var listContent = Assert.Single(listRequestBody.Content); Assert.Equal("application/json", listContent.Key); var listSchema = listContent.Value.Schema; - Assert.NotNull(listSchema.AllOf); // AllOf IS used for nullable collections - Assert.Equal(2, listSchema.AllOf.Count); - Assert.Collection(listSchema.AllOf, + Assert.NotNull(listSchema.OneOf); // OneOf IS used for nullable collections + Assert.Equal(2, listSchema.OneOf.Count); + Assert.Collection(listSchema.OneOf, item => { Assert.NotNull(item); @@ -628,9 +628,9 @@ await VerifyOpenApiDocument(builder, document => var enumerableContent = Assert.Single(enumerableRequestBody.Content); Assert.Equal("application/json", enumerableContent.Key); var enumerableSchema = enumerableContent.Value.Schema; - Assert.NotNull(enumerableSchema.AllOf); // AllOf IS used for nullable collections - Assert.Equal(2, enumerableSchema.AllOf.Count); - Assert.Collection(enumerableSchema.AllOf, + Assert.NotNull(enumerableSchema.OneOf); // OneOf IS used for nullable collections + Assert.Equal(2, enumerableSchema.OneOf.Count); + Assert.Collection(enumerableSchema.OneOf, item => { Assert.NotNull(item); @@ -873,9 +873,9 @@ await VerifyOpenApiDocument(builder, document => var resultRequestBody = resultOperation.RequestBody; var resultContent = Assert.Single(resultRequestBody.Content); var resultSchema = resultContent.Value.Schema; - Assert.NotNull(resultSchema.AllOf); - Assert.Equal(2, resultSchema.AllOf.Count); - Assert.Collection(resultSchema.AllOf, + Assert.NotNull(resultSchema.OneOf); + Assert.Equal(2, resultSchema.OneOf.Count); + Assert.Collection(resultSchema.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), item => Assert.Equal(JsonSchemaType.Object, item.Type)); @@ -884,9 +884,9 @@ await VerifyOpenApiDocument(builder, document => var listRequestBody = listOperation.RequestBody; var listContent = Assert.Single(listRequestBody.Content); var listSchema = listContent.Value.Schema; - Assert.NotNull(listSchema.AllOf); - Assert.Equal(2, listSchema.AllOf.Count); - Assert.Collection(listSchema.AllOf, + Assert.NotNull(listSchema.OneOf); + Assert.Equal(2, listSchema.OneOf.Count); + Assert.Collection(listSchema.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), item => { @@ -900,9 +900,9 @@ await VerifyOpenApiDocument(builder, document => var dictRequestBody = dictOperation.RequestBody; var dictContent = Assert.Single(dictRequestBody.Content); var dictSchema = dictContent.Value.Schema; - Assert.NotNull(dictSchema.AllOf); - Assert.Equal(2, dictSchema.AllOf.Count); - Assert.Collection(dictSchema.AllOf, + Assert.NotNull(dictSchema.OneOf); + Assert.Equal(2, dictSchema.OneOf.Count); + Assert.Collection(dictSchema.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), item => { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs index abe4b9b3a6b7..5231d06b299d 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs @@ -182,10 +182,10 @@ await VerifyOpenApiDocument(builder, document => var response = responses.Value; Assert.True(response.Content.TryGetValue("application/json", out var mediaType)); var schema = mediaType.Schema; - Assert.NotNull(schema.AllOf); - Assert.Equal(2, schema.AllOf.Count); - // Check that the allOf consists of a nullable schema and the GetTodo schema - Assert.Collection(schema.AllOf, + Assert.NotNull(schema.OneOf); + Assert.Equal(2, schema.OneOf.Count); + // Check that the oneOf consists of a nullable schema and the GetTodo schema + Assert.Collection(schema.OneOf, item => { Assert.NotNull(item); @@ -363,9 +363,9 @@ await VerifyOpenApiDocument(builder, document => var pointResponse = pointResponses.Value; Assert.True(pointResponse.Content.TryGetValue("application/json", out var pointMediaType)); var pointSchema = pointMediaType.Schema; - Assert.NotNull(pointSchema.AllOf); - Assert.Equal(2, pointSchema.AllOf.Count); - Assert.Collection(pointSchema.AllOf, + Assert.NotNull(pointSchema.OneOf); + Assert.Equal(2, pointSchema.OneOf.Count); + Assert.Collection(pointSchema.OneOf, item => { Assert.NotNull(item); @@ -396,9 +396,9 @@ await VerifyOpenApiDocument(builder, document => var coordinateResponse = coordinateResponses.Value; Assert.True(coordinateResponse.Content.TryGetValue("application/json", out var coordinateMediaType)); var coordinateSchema = coordinateMediaType.Schema; - Assert.NotNull(coordinateSchema.AllOf); - Assert.Equal(2, coordinateSchema.AllOf.Count); - Assert.Collection(coordinateSchema.AllOf, + Assert.NotNull(coordinateSchema.OneOf); + Assert.Equal(2, coordinateSchema.OneOf.Count); + Assert.Collection(coordinateSchema.OneOf, item => { Assert.NotNull(item); @@ -451,14 +451,14 @@ public async Task GetOpenApiResponse_HandlesNullableCollectionResponsesWithAllOf // Assert await VerifyOpenApiDocument(builder, document => { - // Verify nullable List response uses allOf + // Verify nullable List response uses oneOf var listOperation = document.Paths["/api/nullable-list"].Operations[HttpMethod.Get]; var listResponse = Assert.Single(listOperation.Responses).Value; Assert.True(listResponse.Content.TryGetValue("application/json", out var listMediaType)); var listSchema = listMediaType.Schema; - Assert.NotNull(listSchema.AllOf); - Assert.Equal(2, listSchema.AllOf.Count); - Assert.Collection(listSchema.AllOf, + Assert.NotNull(listSchema.OneOf); + Assert.Equal(2, listSchema.OneOf.Count); + Assert.Collection(listSchema.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), item => { @@ -467,14 +467,14 @@ await VerifyOpenApiDocument(builder, document => Assert.Equal("Todo", ((OpenApiSchemaReference)item.Items).Reference.Id); }); - // Verify nullable Todo[] response uses allOf + // Verify nullable Todo[] response uses oneOf var arrayOperation = document.Paths["/api/nullable-array"].Operations[HttpMethod.Get]; var arrayResponse = Assert.Single(arrayOperation.Responses).Value; Assert.True(arrayResponse.Content.TryGetValue("application/json", out var arrayMediaType)); var arraySchema = arrayMediaType.Schema; - Assert.NotNull(arraySchema.AllOf); - Assert.Equal(2, arraySchema.AllOf.Count); - Assert.Collection(arraySchema.AllOf, + Assert.NotNull(arraySchema.OneOf); + Assert.Equal(2, arraySchema.OneOf.Count); + Assert.Collection(arraySchema.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), item => { @@ -483,14 +483,14 @@ await VerifyOpenApiDocument(builder, document => Assert.Equal("Todo", ((OpenApiSchemaReference)item.Items).Reference.Id); }); - // Verify nullable IEnumerable response uses allOf + // Verify nullable IEnumerable response uses oneOf var enumerableOperation = document.Paths["/api/nullable-enumerable"].Operations[HttpMethod.Get]; var enumerableResponse = Assert.Single(enumerableOperation.Responses).Value; Assert.True(enumerableResponse.Content.TryGetValue("application/json", out var enumerableMediaType)); var enumerableSchema = enumerableMediaType.Schema; - Assert.NotNull(enumerableSchema.AllOf); - Assert.Equal(2, enumerableSchema.AllOf.Count); - Assert.Collection(enumerableSchema.AllOf, + Assert.NotNull(enumerableSchema.OneOf); + Assert.Equal(2, enumerableSchema.OneOf.Count); + Assert.Collection(enumerableSchema.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), item => { @@ -519,14 +519,14 @@ public async Task GetOpenApiResponse_HandlesNullableEnumResponsesWithAllOf() // Assert await VerifyOpenApiDocument(builder, document => { - // Verify nullable Status (with string converter) response uses allOf + // Verify nullable Status (with string converter) response uses oneOf var statusOperation = document.Paths["/api/nullable-status"].Operations[HttpMethod.Get]; var statusResponse = Assert.Single(statusOperation.Responses).Value; Assert.True(statusResponse.Content.TryGetValue("application/json", out var statusMediaType)); var statusSchema = statusMediaType.Schema; - Assert.NotNull(statusSchema.AllOf); - Assert.Equal(2, statusSchema.AllOf.Count); - Assert.Collection(statusSchema.AllOf, + Assert.NotNull(statusSchema.OneOf); + Assert.Equal(2, statusSchema.OneOf.Count); + Assert.Collection(statusSchema.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), item => { @@ -534,14 +534,14 @@ await VerifyOpenApiDocument(builder, document => Assert.Equal("Status", ((OpenApiSchemaReference)item).Reference.Id); }); - // Verify nullable TaskStatus (without converter) response uses allOf + // Verify nullable TaskStatus (without converter) response uses oneOf var taskStatusOperation = document.Paths["/api/nullable-task-status"].Operations[HttpMethod.Get]; var taskStatusResponse = Assert.Single(taskStatusOperation.Responses).Value; Assert.True(taskStatusResponse.Content.TryGetValue("application/json", out var taskStatusMediaType)); var taskStatusSchema = taskStatusMediaType.Schema; - Assert.NotNull(taskStatusSchema.AllOf); - Assert.Equal(2, taskStatusSchema.AllOf.Count); - Assert.Collection(taskStatusSchema.AllOf, + Assert.NotNull(taskStatusSchema.OneOf); + Assert.Equal(2, taskStatusSchema.OneOf.Count); + Assert.Collection(taskStatusSchema.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), item => Assert.Equal(JsonSchemaType.Integer, item.Type)); }); diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/Implementations/OpenApiSchemaReferenceTransformerTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/Implementations/OpenApiSchemaReferenceTransformerTests.cs index b1c76676dd70..1d49c03970b5 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/Implementations/OpenApiSchemaReferenceTransformerTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/Implementations/OpenApiSchemaReferenceTransformerTests.cs @@ -979,8 +979,8 @@ await VerifyOpenApiDocument(builder, document => // Check secondaryUser property (nullable RefProfile) var secondaryUserSchema = requestSchema.Properties!["secondaryUser"]; - Assert.NotNull(secondaryUserSchema.AllOf); - Assert.Collection(secondaryUserSchema.AllOf, + Assert.NotNull(secondaryUserSchema.OneOf); + Assert.Collection(secondaryUserSchema.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), item => Assert.Equal("RefProfile", ((OpenApiSchemaReference)item).Reference.Id)); @@ -995,7 +995,7 @@ await VerifyOpenApiDocument(builder, document => Assert.Contains("email", userSchemaContent.Properties?.Keys ?? []); // Both properties should reference the same RefProfile schema - var secondaryUserSchemaRef = secondaryUserSchema.AllOf.Last(); + var secondaryUserSchemaRef = secondaryUserSchema.OneOf.Last(); Assert.Equal(((OpenApiSchemaReference)primaryUserSchema).Reference.Id, ((OpenApiSchemaReference)secondaryUserSchemaRef).Reference.Id); From 0589aa2bf0ada372893533d6083e6ac247d961f8 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Mon, 18 Aug 2025 14:33:24 -0700 Subject: [PATCH 7/8] Update tests --- .../sample/Endpoints/MapSchemasEndpoints.cs | 10 +- ...t_documentName=schemas-by-ref.verified.txt | 93 +++++++++++++++++- ...t_documentName=schemas-by-ref.verified.txt | 95 ++++++++++++++++++- ...ifyOpenApiDocumentIsInvariant.verified.txt | 95 ++++++++++++++++++- 4 files changed, 285 insertions(+), 8 deletions(-) diff --git a/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs b/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs index 3a18f47993b3..958a2fd9c47c 100644 --- a/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs +++ b/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs @@ -54,13 +54,19 @@ public static IEndpointRouteBuilder MapSchemasEndpoints(this IEndpointRouteBuild NullableProperty = null, NullableComplexProperty = null })); + schemas.MapGet("/nullable-return-type", NullableResponseModel? () => new NullableResponseModel + { + RequiredProperty = "required", + NullableProperty = null, + NullableComplexProperty = null + }); schemas.MapPost("/nullable-request", (NullableRequestModel? request) => Results.Ok(request)); schemas.MapPost("/complex-nullable-hierarchy", (ComplexHierarchyModel model) => Results.Ok(model)); // Additional edge cases for nullable testing schemas.MapPost("/nullable-array-elements", (NullableArrayModel model) => Results.Ok(model)); - schemas.MapGet("/optional-with-default", () => Results.Ok(new ModelWithDefaults())); - schemas.MapGet("/nullable-enum-response", () => Results.Ok(new EnumNullableModel + schemas.MapGet("/optional-with-default", () => TypedResults.Ok(new ModelWithDefaults())); + schemas.MapGet("/nullable-enum-response", () => TypedResults.Ok(new EnumNullableModel { RequiredEnum = TestEnum.Value1, NullableEnum = null 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 a88207f4c918..648510972ee6 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 @@ -656,6 +656,32 @@ } } }, + "/schemas-by-ref/nullable-return-type": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "nullable": true + }, + { + "$ref": "#/components/schemas/NullableResponseModel" + } + ] + } + } + } + } + } + } + }, "/schemas-by-ref/nullable-request": { "post": { "tags": [ @@ -735,7 +761,14 @@ ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelWithDefaults" + } + } + } } } } @@ -747,7 +780,14 @@ ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnumNullableModel" + } + } + } } } } @@ -948,6 +988,33 @@ } } }, + "EnumNullableModel": { + "required": [ + "requiredEnum" + ], + "type": "object", + "properties": { + "requiredEnum": { + "$ref": "#/components/schemas/TestEnum" + }, + "nullableEnum": { + "oneOf": [ + { + "nullable": true + }, + { + "$ref": "#/components/schemas/TestEnum" + } + ] + }, + "listOfNullableEnums": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TestEnum" + } + } + } + }, "Item": { "type": "object", "properties": { @@ -1059,6 +1126,25 @@ } } }, + "ModelWithDefaults": { + "type": "object", + "properties": { + "propertyWithDefault": { + "type": "string" + }, + "nullableWithNull": { + "type": "string", + "nullable": true + }, + "numberWithDefault": { + "type": "integer", + "format": "int32" + }, + "boolWithDefault": { + "type": "boolean" + } + } + }, "NestedModel": { "required": [ "name" @@ -1411,6 +1497,9 @@ } } }, + "TestEnum": { + "type": "integer" + }, "Triangle": { "type": "object", "properties": { 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 481347954159..61d0527bc80f 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 @@ -656,6 +656,32 @@ } } }, + "/schemas-by-ref/nullable-return-type": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NullableResponseModel" + } + ] + } + } + } + } + } + } + }, "/schemas-by-ref/nullable-request": { "post": { "tags": [ @@ -735,7 +761,14 @@ ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelWithDefaults" + } + } + } } } } @@ -747,7 +780,14 @@ ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnumNullableModel" + } + } + } } } } @@ -964,6 +1004,33 @@ } } }, + "EnumNullableModel": { + "required": [ + "requiredEnum" + ], + "type": "object", + "properties": { + "requiredEnum": { + "$ref": "#/components/schemas/TestEnum" + }, + "nullableEnum": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/TestEnum" + } + ] + }, + "listOfNullableEnums": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TestEnum" + } + } + } + }, "Item": { "type": "object", "properties": { @@ -1075,6 +1142,27 @@ } } }, + "ModelWithDefaults": { + "type": "object", + "properties": { + "propertyWithDefault": { + "type": "string" + }, + "nullableWithNull": { + "type": [ + "null", + "string" + ] + }, + "numberWithDefault": { + "type": "integer", + "format": "int32" + }, + "boolWithDefault": { + "type": "boolean" + } + } + }, "NestedModel": { "required": [ "name" @@ -1441,6 +1529,9 @@ } } }, + "TestEnum": { + "type": "integer" + }, "Triangle": { "type": "object", "properties": { 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 9f08bd7373fb..eec2cfe16702 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 @@ -1181,6 +1181,32 @@ } } }, + "/schemas-by-ref/nullable-return-type": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NullableResponseModel" + } + ] + } + } + } + } + } + } + }, "/schemas-by-ref/nullable-request": { "post": { "tags": [ @@ -1260,7 +1286,14 @@ ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelWithDefaults" + } + } + } } } } @@ -1272,7 +1305,14 @@ ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnumNullableModel" + } + } + } } } } @@ -1714,6 +1754,33 @@ } } }, + "EnumNullableModel": { + "required": [ + "requiredEnum" + ], + "type": "object", + "properties": { + "requiredEnum": { + "$ref": "#/components/schemas/TestEnum" + }, + "nullableEnum": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/TestEnum" + } + ] + }, + "listOfNullableEnums": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TestEnum" + } + } + } + }, "IFormFile": { "type": "string", "format": "binary" @@ -1835,6 +1902,27 @@ } } }, + "ModelWithDefaults": { + "type": "object", + "properties": { + "propertyWithDefault": { + "type": "string" + }, + "nullableWithNull": { + "type": [ + "null", + "string" + ] + }, + "numberWithDefault": { + "type": "integer", + "format": "int32" + }, + "boolWithDefault": { + "type": "boolean" + } + } + }, "MvcTodo": { "required": [ "title", @@ -2254,6 +2342,9 @@ } } }, + "TestEnum": { + "type": "integer" + }, "Todo": { "required": [ "id", From cf17179589bdaf1978530a8bc5d9e83d0fa3fa69 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Mon, 18 Aug 2025 16:54:28 -0700 Subject: [PATCH 8/8] Address more feedback --- .../Extensions/JsonNodeSchemaExtensions.cs | 4 +- .../src/Services/OpenApiDocumentService.cs | 4 +- .../Services/Schemas/OpenApiSchemaService.cs | 4 +- .../OpenApiSchemaService.PropertySchemas.cs | 56 +++++++++---------- ...OpenApiSchemaService.RequestBodySchemas.cs | 12 ++-- .../OpenApiSchemaService.ResponseSchemas.cs | 4 +- 6 files changed, 42 insertions(+), 42 deletions(-) diff --git a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs index eb1c1b4543f3..553c87643557 100644 --- a/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs +++ b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs @@ -469,9 +469,9 @@ internal static void ApplyNullabilityContextInfo(this JsonNode schema, JsonPrope /// /// Prunes the "null" type from the schema for types that are componentized. These - /// types should represent their nullability using allOf with null instead. + /// types should represent their nullability using oneOf with null instead. /// - /// + /// The produced by the underlying schema generator. internal static void PruneNullTypeForComponentizedTypes(this JsonNode schema) { if (schema[OpenApiConstants.SchemaId] is not null && diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index 68e0ee2bd416..f342f5b7943b 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -423,7 +423,7 @@ private async Task GetResponseAsync( .Select(responseFormat => responseFormat.MediaType); foreach (var contentType in apiResponseFormatContentTypes) { - IOpenApiSchema schema = new OpenApiSchema(); + IOpenApiSchema? schema = null; if (apiResponseType.Type is { } responseType) { schema = await _componentService.GetOrCreateSchemaAsync(document, responseType, scopedServiceProvider, schemaTransformers, null, cancellationToken); @@ -431,7 +431,7 @@ private async Task GetResponseAsync( ? schema.CreateOneOfNullableWrapper() : schema; } - response.Content[contentType] = new OpenApiMediaType { Schema = schema }; + response.Content[contentType] = new OpenApiMediaType { Schema = schema ?? new OpenApiSchema() }; } // MVC's `ProducesAttribute` doesn't implement the produces metadata that the ApiExplorer diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index b223b5ad3a3f..12b0c1ed996c 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -281,16 +281,16 @@ internal static IOpenApiSchema ResolveReferenceForSchema(OpenApiDocument documen { foreach (var property in schema.Properties) { + var resolvedProperty = ResolveReferenceForSchema(document, property.Value, rootSchemaId); if (property.Value is OpenApiSchema targetSchema && targetSchema.Metadata?.TryGetValue(OpenApiConstants.NullableProperty, out var isNullableProperty) == true && isNullableProperty is true) { - var resolvedProperty = ResolveReferenceForSchema(document, property.Value, rootSchemaId); schema.Properties[property.Key] = resolvedProperty.CreateOneOfNullableWrapper(); } else { - schema.Properties[property.Key] = ResolveReferenceForSchema(document, property.Value, rootSchemaId); + schema.Properties[property.Key] = resolvedProperty; } } } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.PropertySchemas.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.PropertySchemas.cs index 07305bb59b0d..0773249e195d 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.PropertySchemas.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.PropertySchemas.cs @@ -29,11 +29,11 @@ await VerifyOpenApiDocument(builder, document => Assert.Equal(JsonSchemaType.Object, schema.Type); - // Check nullable int property has null in type directly or uses allOf + // Check nullable int property has null in type directly or uses oneOf var nullableIntProperty = schema.Properties["nullableInt"]; if (nullableIntProperty.OneOf != null) { - // If still uses allOf, verify structure + // If still uses oneOf, verify structure Assert.Equal(2, nullableIntProperty.OneOf.Count); Assert.Collection(nullableIntProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), @@ -51,11 +51,11 @@ await VerifyOpenApiDocument(builder, document => Assert.Equal("int32", nullableIntProperty.Format); } - // Check nullable string property has null in type directly or uses allOf + // Check nullable string property has null in type directly or uses oneOf var nullableStringProperty = schema.Properties["nullableString"]; if (nullableStringProperty.OneOf != null) { - // If still uses allOf, verify structure + // If still uses oneOf, verify structure Assert.Equal(2, nullableStringProperty.OneOf.Count); Assert.Collection(nullableStringProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), @@ -68,11 +68,11 @@ await VerifyOpenApiDocument(builder, document => Assert.True(nullableStringProperty.Type?.HasFlag(JsonSchemaType.Null)); } - // Check nullable bool property has null in type directly or uses allOf + // Check nullable bool property has null in type directly or uses oneOf var nullableBoolProperty = schema.Properties["nullableBool"]; if (nullableBoolProperty.OneOf != null) { - // If still uses allOf, verify structure + // If still uses oneOf, verify structure Assert.Equal(2, nullableBoolProperty.OneOf.Count); Assert.Collection(nullableBoolProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), @@ -85,11 +85,11 @@ await VerifyOpenApiDocument(builder, document => Assert.True(nullableBoolProperty.Type?.HasFlag(JsonSchemaType.Null)); } - // Check nullable DateTime property has null in type directly or uses allOf + // Check nullable DateTime property has null in type directly or uses oneOf var nullableDateTimeProperty = schema.Properties["nullableDateTime"]; if (nullableDateTimeProperty.OneOf != null) { - // If still uses allOf, verify structure + // If still uses oneOf, verify structure Assert.Equal(2, nullableDateTimeProperty.OneOf.Count); Assert.Collection(nullableDateTimeProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), @@ -107,11 +107,11 @@ await VerifyOpenApiDocument(builder, document => Assert.Equal("date-time", nullableDateTimeProperty.Format); } - // Check nullable Guid property has null in type directly or uses allOf + // Check nullable Guid property has null in type directly or uses oneOf var nullableGuidProperty = schema.Properties["nullableGuid"]; if (nullableGuidProperty.OneOf != null) { - // If still uses allOf, verify structure + // If still uses oneOf, verify structure Assert.Equal(2, nullableGuidProperty.OneOf.Count); Assert.Collection(nullableGuidProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), @@ -129,11 +129,11 @@ await VerifyOpenApiDocument(builder, document => Assert.Equal("uuid", nullableGuidProperty.Format); } - // Check nullable Uri property has null in type directly or uses allOf + // Check nullable Uri property has null in type directly or uses oneOf var nullableUriProperty = schema.Properties["nullableUri"]; if (nullableUriProperty.OneOf != null) { - // If still uses allOf, verify structure + // If still uses oneOf, verify structure Assert.Equal(2, nullableUriProperty.OneOf.Count); Assert.Collection(nullableUriProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), @@ -172,7 +172,7 @@ await VerifyOpenApiDocument(builder, document => Assert.Equal(JsonSchemaType.Object, schema.Type); - // Check nullable Todo property uses allOf with reference + // Check nullable Todo property uses oneOf with reference var nullableTodoProperty = schema.Properties["nullableTodo"]; Assert.NotNull(nullableTodoProperty.OneOf); Assert.Equal(2, nullableTodoProperty.OneOf.Count); @@ -180,7 +180,7 @@ await VerifyOpenApiDocument(builder, document => item => Assert.Equal(JsonSchemaType.Null, item.Type), item => Assert.Equal("Todo", ((OpenApiSchemaReference)item).Reference.Id)); - // Check nullable Account property uses allOf with reference + // Check nullable Account property uses oneOf with reference var nullableAccountProperty = schema.Properties["nullableAccount"]; Assert.NotNull(nullableAccountProperty.OneOf); Assert.Equal(2, nullableAccountProperty.OneOf.Count); @@ -213,11 +213,11 @@ await VerifyOpenApiDocument(builder, document => Assert.Equal(JsonSchemaType.Object, schema.Type); - // Check nullable List property has null in type or uses allOf + // Check nullable List property has null in type or uses oneOf var nullableTodoListProperty = schema.Properties["nullableTodoList"]; if (nullableTodoListProperty.OneOf != null) { - // If still uses allOf, verify structure + // If still uses oneOf, verify structure Assert.Equal(2, nullableTodoListProperty.OneOf.Count); Assert.Collection(nullableTodoListProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), @@ -235,11 +235,11 @@ await VerifyOpenApiDocument(builder, document => Assert.True(nullableTodoListProperty.Type?.HasFlag(JsonSchemaType.Null)); } - // Check nullable Todo[] property has null in type or uses allOf + // Check nullable Todo[] property has null in type or uses oneOf var nullableTodoArrayProperty = schema.Properties["nullableTodoArray"]; if (nullableTodoArrayProperty.OneOf != null) { - // If still uses allOf, verify structure + // If still uses oneOf, verify structure Assert.Equal(2, nullableTodoArrayProperty.OneOf.Count); Assert.Collection(nullableTodoArrayProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), @@ -257,11 +257,11 @@ await VerifyOpenApiDocument(builder, document => Assert.True(nullableTodoArrayProperty.Type?.HasFlag(JsonSchemaType.Null)); } - // Check nullable Dictionary property has null in type or uses allOf + // Check nullable Dictionary property has null in type or uses oneOf var nullableDictionaryProperty = schema.Properties["nullableDictionary"]; if (nullableDictionaryProperty.OneOf != null) { - // If still uses allOf, verify structure + // If still uses oneOf, verify structure Assert.Equal(2, nullableDictionaryProperty.OneOf.Count); Assert.Collection(nullableDictionaryProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), @@ -300,7 +300,7 @@ await VerifyOpenApiDocument(builder, document => Assert.Equal(JsonSchemaType.Object, schema.Type); - // Check nullable Status (with string converter) property uses allOf with reference + // Check nullable Status (with string converter) property uses oneOf with reference var nullableStatusProperty = schema.Properties["nullableStatus"]; Assert.NotNull(nullableStatusProperty.OneOf); Assert.Equal(2, nullableStatusProperty.OneOf.Count); @@ -308,7 +308,7 @@ await VerifyOpenApiDocument(builder, document => item => Assert.Equal(JsonSchemaType.Null, item.Type), item => Assert.Equal("Status", ((OpenApiSchemaReference)item).Reference.Id)); - // Check nullable TaskStatus (without converter) property uses allOf + // Check nullable TaskStatus (without converter) property uses oneOf var nullableTaskStatusProperty = schema.Properties["nullableTaskStatus"]; Assert.NotNull(nullableTaskStatusProperty.OneOf); Assert.Equal(2, nullableTaskStatusProperty.OneOf.Count); @@ -337,11 +337,11 @@ await VerifyOpenApiDocument(builder, document => Assert.Equal(JsonSchemaType.Object, schema.Type); - // Check nullable string with validation attributes has null in type or uses allOf + // Check nullable string with validation attributes has null in type or uses oneOf var nullableNameProperty = schema.Properties["nullableName"]; if (nullableNameProperty.OneOf != null) { - // If still uses allOf for properties with validation, verify structure + // If still uses oneOf for properties with validation, verify structure Assert.Equal(2, nullableNameProperty.OneOf.Count); Assert.Collection(nullableNameProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), @@ -361,11 +361,11 @@ await VerifyOpenApiDocument(builder, document => Assert.Equal(50, nullableNameProperty.MaxLength); } - // Check nullable int with range validation has null in type or uses allOf + // Check nullable int with range validation has null in type or uses oneOf var nullableAgeProperty = schema.Properties["nullableAge"]; if (nullableAgeProperty.OneOf != null) { - // If still uses allOf for properties with validation, verify structure + // If still uses oneOf for properties with validation, verify structure Assert.Equal(2, nullableAgeProperty.OneOf.Count); Assert.Collection(nullableAgeProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), @@ -387,11 +387,11 @@ await VerifyOpenApiDocument(builder, document => Assert.Equal("120", nullableAgeProperty.Maximum); } - // Check nullable string with description has null in type or uses allOf + // Check nullable string with description has null in type or uses oneOf var nullableDescriptionProperty = schema.Properties["nullableDescription"]; if (nullableDescriptionProperty.OneOf != null) { - // If still uses allOf for properties with description, verify structure + // If still uses oneOf for properties with description, verify structure Assert.Equal(2, nullableDescriptionProperty.OneOf.Count); Assert.Collection(nullableDescriptionProperty.OneOf, item => Assert.Equal(JsonSchemaType.Null, item.Type), diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.RequestBodySchemas.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.RequestBodySchemas.cs index a542dd59d6c7..2c368e559c49 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.RequestBodySchemas.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.RequestBodySchemas.cs @@ -465,7 +465,7 @@ await VerifyOpenApiDocument(builder, document => } [Fact] - public async Task GetOpenApiRequestBody_HandlesNullableParameterWithAllOf() + public async Task GetOpenApiRequestBody_HandlesNullableParameterWithOneOf() { // Arrange var builder = CreateBuilder(); @@ -563,7 +563,7 @@ await VerifyOpenApiDocument(builder, document => } [Fact] - public async Task GetOpenApiRequestBody_HandlesNullableCollectionParametersWithAllOf() + public async Task GetOpenApiRequestBody_HandlesNullableCollectionParametersWithOneOf() { // Arrange var builder = CreateBuilder(); @@ -578,7 +578,7 @@ public async Task GetOpenApiRequestBody_HandlesNullableCollectionParametersWithA // Assert await VerifyOpenApiDocument(builder, document => { - // Verify nullable array parameter - verify actual behavior with AllOf + // Verify nullable array parameter - verify actual behavior with OneOf var arrayOperation = document.Paths["/api/nullable-array"].Operations[HttpMethod.Post]; var arrayRequestBody = arrayOperation.RequestBody; var arrayContent = Assert.Single(arrayRequestBody.Content); @@ -600,7 +600,7 @@ await VerifyOpenApiDocument(builder, document => Assert.Equal("Todo", ((OpenApiSchemaReference)item.Items).Reference.Id); }); - // Verify nullable List parameter - verify actual behavior with AllOf + // Verify nullable List parameter - verify actual behavior with OneOf var listOperation = document.Paths["/api/nullable-list"].Operations[HttpMethod.Post]; var listRequestBody = listOperation.RequestBody; var listContent = Assert.Single(listRequestBody.Content); @@ -622,7 +622,7 @@ await VerifyOpenApiDocument(builder, document => Assert.Equal("Todo", ((OpenApiSchemaReference)item.Items).Reference.Id); }); - // Verify nullable IEnumerable parameter - verify actual behavior with AllOf + // Verify nullable IEnumerable parameter - verify actual behavior with OneOf var enumerableOperation = document.Paths["/api/nullable-enumerable"].Operations[HttpMethod.Post]; var enumerableRequestBody = enumerableOperation.RequestBody; var enumerableContent = Assert.Single(enumerableRequestBody.Content); @@ -853,7 +853,7 @@ private class ExampleWithSkippedUnmappedMembers } [Fact] - public async Task GetOpenApiRequestBody_HandlesNullableGenericTypesWithAllOf() + public async Task GetOpenApiRequestBody_HandlesNullableGenericTypesWithOneOf() { // Arrange var builder = CreateBuilder(); diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs index 5231d06b299d..c0a38a43997b 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs @@ -429,7 +429,7 @@ await VerifyOpenApiDocument(builder, document => } [Fact] - public async Task GetOpenApiResponse_HandlesNullableCollectionResponsesWithAllOf() + public async Task GetOpenApiResponse_HandlesNullableCollectionResponsesWithOneOf() { // Arrange var builder = CreateBuilder(); @@ -502,7 +502,7 @@ await VerifyOpenApiDocument(builder, document => } [Fact] - public async Task GetOpenApiResponse_HandlesNullableEnumResponsesWithAllOf() + public async Task GetOpenApiResponse_HandlesNullableEnumResponsesWithOneOf() { // Arrange var builder = CreateBuilder();