diff --git a/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs b/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs index 23591e8efb18..958a2fd9c47c 100644 --- a/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs +++ b/src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs @@ -46,6 +46,32 @@ 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 oneOf nullable behavior on responses and request bodies + schemas.MapGet("/nullable-response", () => TypedResults.Ok(new NullableResponseModel + { + RequiredProperty = "required", + 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", () => TypedResults.Ok(new ModelWithDefaults())); + schemas.MapGet("/nullable-enum-response", () => TypedResults.Ok(new EnumNullableModel + { + RequiredEnum = TestEnum.Value1, + NullableEnum = null + })); + return endpointRouteBuilder; } @@ -173,4 +199,73 @@ public sealed class RefUser public string Name { get; set; } = ""; public string Email { get; set; } = ""; } + + // Models for testing oneOf 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/src/Extensions/JsonNodeSchemaExtensions.cs b/src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs index feb32d33b8e0..553c87643557 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 @@ -451,42 +444,49 @@ private static bool IsNonAbstractTypeWithoutDerivedTypeReference(JsonSchemaExpor } /// - /// Support applying nullability status for reference types provided as a parameter. + /// Support applying nullability status for reference types provided as a property or field. /// /// The produced by the underlying schema generator. - /// The associated with the schema. - internal static void ApplyNullabilityContextInfo(this JsonNode schema, ParameterInfo parameterInfo) + /// The associated with the schema. + internal static void ApplyNullabilityContextInfo(this JsonNode schema, JsonPropertyInfo propertyInfo) { - if (parameterInfo.ParameterType.IsValueType) + // Avoid setting explicit nullability annotations for `object` types so they continue to match on the catch + // all schema (no type, no format, no constraints). + if (propertyInfo.PropertyType != typeof(object) && (propertyInfo.IsGetNullable || propertyInfo.IsSetNullable)) { - return; + if (MapJsonNodeToSchemaType(schema[OpenApiSchemaKeywords.TypeKeyword]) is { } schemaTypes && + !schemaTypes.HasFlag(JsonSchemaType.Null)) + { + schema[OpenApiSchemaKeywords.TypeKeyword] = (schemaTypes | JsonSchemaType.Null).ToString(); + } } - - var nullabilityInfoContext = new NullabilityInfoContext(); - var nullabilityInfo = nullabilityInfoContext.Create(parameterInfo); - if (nullabilityInfo.WriteState == NullabilityState.Nullable - && MapJsonNodeToSchemaType(schema[OpenApiSchemaKeywords.TypeKeyword]) is { } schemaTypes - && !schemaTypes.HasFlag(JsonSchemaType.Null)) + if (schema[OpenApiConstants.SchemaId] is not null && + propertyInfo.PropertyType != typeof(object) && propertyInfo.ShouldApplyNullablePropertySchema()) { - schema[OpenApiSchemaKeywords.TypeKeyword] = (schemaTypes | JsonSchemaType.Null).ToString(); + schema[OpenApiConstants.NullableProperty] = true; } } /// - /// Support applying nullability status for reference types provided as a property or field. + /// Prunes the "null" type from the schema for types that are componentized. These + /// types should represent their nullability using oneOf with null instead. /// /// The produced by the underlying schema generator. - /// The associated with the schema. - internal static void ApplyNullabilityContextInfo(this JsonNode schema, JsonPropertyInfo propertyInfo) + internal static void PruneNullTypeForComponentizedTypes(this JsonNode schema) { - // Avoid setting explicit nullability annotations for `object` types so they continue to match on the catch - // all schema (no type, no format, no constraints). - if (propertyInfo.PropertyType != typeof(object) && (propertyInfo.IsGetNullable || propertyInfo.IsSetNullable)) + if (schema[OpenApiConstants.SchemaId] is not null && + schema[OpenApiSchemaKeywords.TypeKeyword] is JsonArray typeArray) { - if (MapJsonNodeToSchemaType(schema[OpenApiSchemaKeywords.TypeKeyword]) is { } schemaTypes && - !schemaTypes.HasFlag(JsonSchemaType.Null)) + for (var i = typeArray.Count - 1; i >= 0; i--) { - schema[OpenApiSchemaKeywords.TypeKeyword] = (schemaTypes | JsonSchemaType.Null).ToString(); + if (typeArray[i]?.GetValue() == "null") + { + typeArray.RemoveAt(i); + } + } + if (typeArray.Count == 1) + { + schema[OpenApiSchemaKeywords.TypeKeyword] = typeArray[0]?.GetValue(); } } } 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..f394445850fe --- /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 CreateOneOfNullableWrapper(this IOpenApiSchema originalSchema) + { + return new OpenApiSchema + { + OneOf = + [ + _nullSchema, + originalSchema + ] + }; + } +} diff --git a/src/OpenApi/src/Extensions/TypeExtensions.cs b/src/OpenApi/src/Extensions/TypeExtensions.cs index e2ba5f500c63..0636347bf5bc 100644 --- a/src/OpenApi/src/Extensions/TypeExtensions.cs +++ b/src/OpenApi/src/Extensions/TypeExtensions.cs @@ -1,6 +1,13 @@ // 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 System.Text.Json.Serialization.Metadata; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Infrastructure; + namespace Microsoft.AspNetCore.OpenApi; internal static class TypeExtensions @@ -30,4 +37,73 @@ 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; + } + + 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; + } + + 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/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index 5308dc3792a1..f342f5b7943b 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -423,8 +423,15 @@ 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(); - response.Content[contentType] = new OpenApiMediaType { Schema = schema }; + IOpenApiSchema? schema = null; + if (apiResponseType.Type is { } responseType) + { + schema = await _componentService.GetOrCreateSchemaAsync(document, responseType, scopedServiceProvider, schemaTransformers, null, cancellationToken); + schema = apiResponseType.ShouldApplyNullableResponseSchema(apiDescription) + ? schema.CreateOneOfNullableWrapper() + : schema; + } + response.Content[contentType] = new OpenApiMediaType { Schema = schema ?? new OpenApiSchema() }; } // MVC's `ProducesAttribute` doesn't implement the produces metadata that the ApiExplorer @@ -744,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.CreateOneOfNullableWrapper() + : schema; + requestBody.Content[contentType] = new OpenApiMediaType { Schema = schema }; } return requestBody; diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index d35ab8bb449e..12b0c1ed996c 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.PruneNullTypeForComponentizedTypes(); return schema; } }; @@ -281,7 +281,17 @@ internal static IOpenApiSchema ResolveReferenceForSchema(OpenApiDocument documen { foreach (var property in schema.Properties) { - schema.Properties[property.Key] = ResolveReferenceForSchema(document, property.Value, rootSchemaId); + 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) + { + schema.Properties[property.Key] = resolvedProperty.CreateOneOfNullableWrapper(); + } + else + { + schema.Properties[property.Key] = resolvedProperty; + } } } 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..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 @@ -636,6 +636,161 @@ } } } + }, + "/schemas-by-ref/nullable-response": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NullableResponseModel" + } + } + } + } + } + } + }, + "/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": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "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", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelWithDefaults" + } + } + } + } + } + } + }, + "/schemas-by-ref/nullable-enum-response": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnumNullableModel" + } + } + } + } + } + } } }, "components": { @@ -707,6 +862,52 @@ } } }, + "ComplexHierarchyModel": { + "required": [ + "id", + "requiredNested" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "optionalNested": { + "oneOf": [ + { + "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": { @@ -787,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": { @@ -898,6 +1126,130 @@ } } }, + "ModelWithDefaults": { + "type": "object", + "properties": { + "propertyWithDefault": { + "type": "string" + }, + "nullableWithNull": { + "type": "string", + "nullable": true + }, + "numberWithDefault": { + "type": "integer", + "format": "int32" + }, + "boolWithDefault": { + "type": "boolean" + } + } + }, + "NestedModel": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "optionalValue": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "deepNested": { + "oneOf": [ + { + "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": { + "oneOf": [ + { + "nullable": true + }, + { + "$ref": "#/components/schemas/ComplexType" + } + ] + } + } + }, "ParentObject": { "type": "object", "properties": { @@ -1022,8 +1374,7 @@ "user": { "$ref": "#/components/schemas/RefUser" } - }, - "nullable": true + } }, "RefUser": { "type": "object", @@ -1124,7 +1475,14 @@ "$ref": "#/components/schemas/RefProfile" }, "secondaryUser": { - "$ref": "#/components/schemas/RefProfile" + "oneOf": [ + { + "nullable": true + }, + { + "$ref": "#/components/schemas/RefProfile" + } + ] } } }, @@ -1139,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 00e055a69541..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 @@ -636,6 +636,161 @@ } } } + }, + "/schemas-by-ref/nullable-response": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NullableResponseModel" + } + } + } + } + } + } + }, + "/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": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "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", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelWithDefaults" + } + } + } + } + } + } + }, + "/schemas-by-ref/nullable-enum-response": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnumNullableModel" + } + } + } + } + } + } } }, "components": { @@ -707,6 +862,58 @@ } } }, + "ComplexHierarchyModel": { + "required": [ + "id", + "requiredNested" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "optionalNested": { + "oneOf": [ + { + "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": { @@ -797,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": { @@ -908,6 +1142,146 @@ } } }, + "ModelWithDefaults": { + "type": "object", + "properties": { + "propertyWithDefault": { + "type": "string" + }, + "nullableWithNull": { + "type": [ + "null", + "string" + ] + }, + "numberWithDefault": { + "type": "integer", + "format": "int32" + }, + "boolWithDefault": { + "type": "boolean" + } + } + }, + "NestedModel": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "optionalValue": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "deepNested": { + "oneOf": [ + { + "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": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ComplexType" + } + ] + } + } + }, "ParentObject": { "type": "object", "properties": { @@ -1027,10 +1401,7 @@ "required": [ "user" ], - "type": [ - "null", - "object" - ], + "type": "object", "properties": { "user": { "$ref": "#/components/schemas/RefUser" @@ -1136,7 +1507,14 @@ "$ref": "#/components/schemas/RefProfile" }, "secondaryUser": { - "$ref": "#/components/schemas/RefProfile" + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/RefProfile" + } + ] } } }, @@ -1151,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 d70cdd320341..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 @@ -1162,6 +1162,161 @@ } } }, + "/schemas-by-ref/nullable-response": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NullableResponseModel" + } + } + } + } + } + } + }, + "/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": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "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", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelWithDefaults" + } + } + } + } + } + } + }, + "/schemas-by-ref/nullable-enum-response": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnumNullableModel" + } + } + } + } + } + } + }, "/responses/200-add-xml": { "get": { "tags": [ @@ -1445,6 +1600,58 @@ } } }, + "ComplexHierarchyModel": { + "required": [ + "id", + "requiredNested" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "optionalNested": { + "oneOf": [ + { + "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": { @@ -1547,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" @@ -1668,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", @@ -1687,6 +1942,125 @@ } } }, + "NestedModel": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "optionalValue": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "deepNested": { + "oneOf": [ + { + "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": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ComplexType" + } + ] + } + } + }, "ParentObject": { "type": "object", "properties": { @@ -1840,10 +2214,7 @@ "required": [ "user" ], - "type": [ - "null", - "object" - ], + "type": "object", "properties": { "user": { "$ref": "#/components/schemas/RefUser" @@ -1949,7 +2320,14 @@ "$ref": "#/components/schemas/RefProfile" }, "secondaryUser": { - "$ref": "#/components/schemas/RefProfile" + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/RefProfile" + } + ] } } }, @@ -1964,6 +2342,9 @@ } } }, + "TestEnum": { + "type": "integer" + }, "Todo": { "required": [ "id", 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..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 @@ -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))] @@ -890,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.OneOf != null) + { + // If it now uses oneOf, verify the structure + Assert.Equal(2, todoSchema.OneOf.Count); + Assert.Collection(todoSchema.OneOf, + 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.OneOf != null) + { + // If it now uses oneOf, verify the structure + Assert.Equal(2, accountSchema.OneOf.Count); + Assert.Collection(accountSchema.OneOf, + 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 @@ -913,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..0773249e195d --- /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 oneOf + var nullableIntProperty = schema.Properties["nullableInt"]; + if (nullableIntProperty.OneOf != null) + { + // If still uses oneOf, verify structure + Assert.Equal(2, nullableIntProperty.OneOf.Count); + Assert.Collection(nullableIntProperty.OneOf, + 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 oneOf + var nullableStringProperty = schema.Properties["nullableString"]; + if (nullableStringProperty.OneOf != null) + { + // If still uses oneOf, verify structure + Assert.Equal(2, nullableStringProperty.OneOf.Count); + Assert.Collection(nullableStringProperty.OneOf, + 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 oneOf + var nullableBoolProperty = schema.Properties["nullableBool"]; + if (nullableBoolProperty.OneOf != null) + { + // If still uses oneOf, verify structure + Assert.Equal(2, nullableBoolProperty.OneOf.Count); + Assert.Collection(nullableBoolProperty.OneOf, + 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 oneOf + var nullableDateTimeProperty = schema.Properties["nullableDateTime"]; + if (nullableDateTimeProperty.OneOf != null) + { + // If still uses oneOf, verify structure + Assert.Equal(2, nullableDateTimeProperty.OneOf.Count); + Assert.Collection(nullableDateTimeProperty.OneOf, + 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 oneOf + var nullableGuidProperty = schema.Properties["nullableGuid"]; + if (nullableGuidProperty.OneOf != null) + { + // If still uses oneOf, verify structure + Assert.Equal(2, nullableGuidProperty.OneOf.Count); + Assert.Collection(nullableGuidProperty.OneOf, + 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 oneOf + var nullableUriProperty = schema.Properties["nullableUri"]; + if (nullableUriProperty.OneOf != null) + { + // If still uses oneOf, verify structure + Assert.Equal(2, nullableUriProperty.OneOf.Count); + Assert.Collection(nullableUriProperty.OneOf, + 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_HandlesNullableComplexTypesInPropertiesWithOneOf() + { + // 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 oneOf with reference + var nullableTodoProperty = schema.Properties["nullableTodo"]; + 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 oneOf with reference + var nullableAccountProperty = schema.Properties["nullableAccount"]; + 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)); + + // 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 oneOf + var nullableTodoListProperty = schema.Properties["nullableTodoList"]; + if (nullableTodoListProperty.OneOf != null) + { + // If still uses oneOf, verify structure + Assert.Equal(2, nullableTodoListProperty.OneOf.Count); + Assert.Collection(nullableTodoListProperty.OneOf, + 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 oneOf + var nullableTodoArrayProperty = schema.Properties["nullableTodoArray"]; + if (nullableTodoArrayProperty.OneOf != null) + { + // If still uses oneOf, verify structure + Assert.Equal(2, nullableTodoArrayProperty.OneOf.Count); + Assert.Collection(nullableTodoArrayProperty.OneOf, + 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 oneOf + var nullableDictionaryProperty = schema.Properties["nullableDictionary"]; + if (nullableDictionaryProperty.OneOf != null) + { + // If still uses oneOf, verify structure + Assert.Equal(2, nullableDictionaryProperty.OneOf.Count); + Assert.Collection(nullableDictionaryProperty.OneOf, + 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_HandlesNullableEnumPropertiesWithOneOf() + { + // 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 oneOf with reference + var nullableStatusProperty = schema.Properties["nullableStatus"]; + 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 oneOf + var nullableTaskStatusProperty = schema.Properties["nullableTaskStatus"]; + 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)); + }); + } + + [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 oneOf + var nullableNameProperty = schema.Properties["nullableName"]; + if (nullableNameProperty.OneOf != null) + { + // 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), + 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 oneOf + var nullableAgeProperty = schema.Properties["nullableAge"]; + if (nullableAgeProperty.OneOf != null) + { + // 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), + 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 oneOf + var nullableDescriptionProperty = schema.Properties["nullableDescription"]; + if (nullableDescriptionProperty.OneOf != null) + { + // 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), + 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 016ea0663edc..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 @@ -464,6 +464,188 @@ await VerifyOpenApiDocument(builder, document => }); } + [Fact] + public async Task GetOpenApiRequestBody_HandlesNullableParameterWithOneOf() + { + // 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.OneOf); + Assert.Equal(2, todoSchema.OneOf.Count); + Assert.Collection(todoSchema.OneOf, + 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.OneOf); + Assert.Equal(2, pointSchema.OneOf.Count); + Assert.Collection(pointSchema.OneOf, + 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_HandlesNullableCollectionParametersWithOneOf() + { + // 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 OneOf + 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.OneOf); // OneOf IS used for nullable collections + Assert.Equal(2, arraySchema.OneOf.Count); + Assert.Collection(arraySchema.OneOf, + 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 OneOf + 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.OneOf); // OneOf IS used for nullable collections + Assert.Equal(2, listSchema.OneOf.Count); + Assert.Collection(listSchema.OneOf, + 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 OneOf + 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.OneOf); // OneOf IS used for nullable collections + Assert.Equal(2, enumerableSchema.OneOf.Count); + Assert.Collection(enumerableSchema.OneOf, + 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() { @@ -670,6 +852,67 @@ private class ExampleWithSkippedUnmappedMembers public int Number { get; init; } } + [Fact] + public async Task GetOpenApiRequestBody_HandlesNullableGenericTypesWithOneOf() + { + // 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.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)); + + // 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.OneOf); + Assert.Equal(2, listSchema.OneOf.Count); + Assert.Collection(listSchema.OneOf, + 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.OneOf); + Assert.Equal(2, dictSchema.OneOf.Count); + Assert.Collection(dictSchema.OneOf, + 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 cabbd30d0c08..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 @@ -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.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); + 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,214 @@ 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.OneOf); + Assert.Equal(2, pointSchema.OneOf.Count); + Assert.Collection(pointSchema.OneOf, + 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.OneOf); + Assert.Equal(2, coordinateSchema.OneOf.Count); + Assert.Collection(coordinateSchema.OneOf, + 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_HandlesNullableCollectionResponsesWithOneOf() + { + // 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 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.OneOf); + Assert.Equal(2, listSchema.OneOf.Count); + Assert.Collection(listSchema.OneOf, + 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 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.OneOf); + Assert.Equal(2, arraySchema.OneOf.Count); + Assert.Collection(arraySchema.OneOf, + 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 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.OneOf); + Assert.Equal(2, enumerableSchema.OneOf.Count); + Assert.Collection(enumerableSchema.OneOf, + 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_HandlesNullableEnumResponsesWithOneOf() + { + // 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 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.OneOf); + Assert.Equal(2, statusSchema.OneOf.Count); + Assert.Collection(statusSchema.OneOf, + 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 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.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)); + }); + } + [Fact] public async Task GetOpenApiResponse_HandlesInheritedTypeResponse() { @@ -313,7 +629,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 => { @@ -339,7 +655,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); @@ -427,7 +743,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 => { @@ -732,4 +1048,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; + } + } } 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..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 @@ -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.OneOf); + Assert.Collection(secondaryUserSchema.OneOf, + 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.OneOf.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))); }); }