Skip to content

Model nullable types using oneOf in OpenAPI schema #63301

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Aug 19, 2025
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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<string>? NullableList { get; set; }
public Dictionary<string, string?>? 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<NestedModel?>? 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<string?> ListWithNullableElements { get; set; } = [];
public Dictionary<string, string?>? 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<TestEnum?> ListOfNullableEnums { get; set; } = [];
}
}
58 changes: 29 additions & 29 deletions src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <see cref="JsonNode"/> and not <see cref="OpenApiSchema"/> 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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -450,28 +443,6 @@ private static bool IsNonAbstractTypeWithoutDerivedTypeReference(JsonSchemaExpor
&& !polymorphismOptions.DerivedTypes.Any(type => type.DerivedType == context.TypeInfo.Type);
}

/// <summary>
/// Support applying nullability status for reference types provided as a parameter.
/// </summary>
/// <param name="schema">The <see cref="JsonNode"/> produced by the underlying schema generator.</param>
/// <param name="parameterInfo">The <see cref="ParameterInfo" /> associated with the schema.</param>
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();
}
}

/// <summary>
/// Support applying nullability status for reference types provided as a property or field.
/// </summary>
Expand All @@ -489,6 +460,35 @@ 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;
}
}

/// <summary>
/// Prunes the "null" type from the schema for types that are componentized. These
/// types should represent their nullability using allOf with null instead.
/// </summary>
/// <param name="schema"></param>
internal static void PruneNullTypeForComponentizedTypes(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<string>() == "null")
{
typeArray.RemoveAt(i);
}
}
if (typeArray.Count == 1)
{
schema[OpenApiSchemaKeywords.TypeKeyword] = typeArray[0]?.GetValue<string>();
}
}
}

private static JsonSchemaType? MapJsonNodeToSchemaType(JsonNode? jsonNode)
Expand Down
6 changes: 6 additions & 0 deletions src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions src/OpenApi/src/Extensions/OpenApiSchemaExtensions.cs
Original file line number Diff line number Diff line change
@@ -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
]
};
}
}
76 changes: 76 additions & 0 deletions src/OpenApi/src/Extensions/TypeExtensions.cs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<MethodInfo>().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;
}
}
5 changes: 5 additions & 0 deletions src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,11 @@ public static void ReadProperty(ref Utf8JsonReader reader, string propertyName,
schema.Metadata ??= new Dictionary<string, object>();
schema.Metadata.Add(OpenApiConstants.SchemaId, reader.GetString() ?? string.Empty);
break;
case OpenApiConstants.NullableProperty:
reader.Read();
schema.Metadata ??= new Dictionary<string, object>();
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:
Expand Down
1 change: 1 addition & 0 deletions src/OpenApi/src/Services/OpenApiConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 13 additions & 2 deletions src/OpenApi/src/Services/OpenApiDocumentService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,14 @@ private async Task<OpenApiResponse> 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 };
}

Expand Down Expand Up @@ -744,7 +751,11 @@ private async Task<OpenApiRequestBody> 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;
Expand Down
14 changes: 12 additions & 2 deletions src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ internal sealed class OpenApiSchemaService(
}
}
}

schema.PruneNullTypeForComponentizedTypes();
return schema;
}
};
Expand Down Expand Up @@ -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);
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);
}
}
}

Expand Down
Loading
Loading