Skip to content

Commit d4dfec9

Browse files
[release/10.0-rc1] Model nullable types using oneOf in OpenAPI schema (#63325)
* Use allOf to model nullable return types * Use allOf to model nullable parameter types * Use allOf for nullable properties * Add test coverage for allOf with nullable * Docs and tweaks * Use oneOf instead of allOf * Update tests * Address more feedback --------- Co-authored-by: Safia Abdalla <safia@safia.rocks>
1 parent 9451f9a commit d4dfec9

17 files changed

+2537
-59
lines changed

src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,32 @@ public static IEndpointRouteBuilder MapSchemasEndpoints(this IEndpointRouteBuild
4646
schemas.MapPost("/config-with-generic-lists", (Config config) => Results.Ok(config));
4747
schemas.MapPost("/project-response", (ProjectResponse project) => Results.Ok(project));
4848
schemas.MapPost("/subscription", (Subscription subscription) => Results.Ok(subscription));
49+
50+
// Tests for oneOf nullable behavior on responses and request bodies
51+
schemas.MapGet("/nullable-response", () => TypedResults.Ok(new NullableResponseModel
52+
{
53+
RequiredProperty = "required",
54+
NullableProperty = null,
55+
NullableComplexProperty = null
56+
}));
57+
schemas.MapGet("/nullable-return-type", NullableResponseModel? () => new NullableResponseModel
58+
{
59+
RequiredProperty = "required",
60+
NullableProperty = null,
61+
NullableComplexProperty = null
62+
});
63+
schemas.MapPost("/nullable-request", (NullableRequestModel? request) => Results.Ok(request));
64+
schemas.MapPost("/complex-nullable-hierarchy", (ComplexHierarchyModel model) => Results.Ok(model));
65+
66+
// Additional edge cases for nullable testing
67+
schemas.MapPost("/nullable-array-elements", (NullableArrayModel model) => Results.Ok(model));
68+
schemas.MapGet("/optional-with-default", () => TypedResults.Ok(new ModelWithDefaults()));
69+
schemas.MapGet("/nullable-enum-response", () => TypedResults.Ok(new EnumNullableModel
70+
{
71+
RequiredEnum = TestEnum.Value1,
72+
NullableEnum = null
73+
}));
74+
4975
return endpointRouteBuilder;
5076
}
5177

@@ -173,4 +199,73 @@ public sealed class RefUser
173199
public string Name { get; set; } = "";
174200
public string Email { get; set; } = "";
175201
}
202+
203+
// Models for testing oneOf nullable behavior
204+
public sealed class NullableResponseModel
205+
{
206+
public required string RequiredProperty { get; set; }
207+
public string? NullableProperty { get; set; }
208+
public ComplexType? NullableComplexProperty { get; set; }
209+
}
210+
211+
public sealed class NullableRequestModel
212+
{
213+
public required string RequiredField { get; set; }
214+
public string? OptionalField { get; set; }
215+
public List<string>? NullableList { get; set; }
216+
public Dictionary<string, string?>? NullableDictionary { get; set; }
217+
}
218+
219+
// Complex hierarchy model for testing nested nullable properties
220+
public sealed class ComplexHierarchyModel
221+
{
222+
public required string Id { get; set; }
223+
public NestedModel? OptionalNested { get; set; }
224+
public required NestedModel RequiredNested { get; set; }
225+
public List<NestedModel?>? NullableListWithNullableItems { get; set; }
226+
}
227+
228+
public sealed class NestedModel
229+
{
230+
public required string Name { get; set; }
231+
public int? OptionalValue { get; set; }
232+
public ComplexType? DeepNested { get; set; }
233+
}
234+
235+
public sealed class ComplexType
236+
{
237+
public string? Description { get; set; }
238+
public DateTime? Timestamp { get; set; }
239+
}
240+
241+
// Additional models for edge case testing
242+
public sealed class NullableArrayModel
243+
{
244+
public string[]? NullableArray { get; set; }
245+
public List<string?> ListWithNullableElements { get; set; } = [];
246+
public Dictionary<string, string?>? NullableDictionaryWithNullableValues { get; set; }
247+
}
248+
249+
public sealed class ModelWithDefaults
250+
{
251+
public string PropertyWithDefault { get; set; } = "default";
252+
public string? NullableWithNull { get; set; }
253+
public int NumberWithDefault { get; set; } = 42;
254+
public bool BoolWithDefault { get; set; } = true;
255+
}
256+
257+
// Enum testing with nullable
258+
public enum TestEnum
259+
{
260+
Value1,
261+
Value2,
262+
Value3
263+
}
264+
265+
public sealed class EnumNullableModel
266+
{
267+
public required TestEnum RequiredEnum { get; set; }
268+
public TestEnum? NullableEnum { get; set; }
269+
public List<TestEnum?> ListOfNullableEnums { get; set; } = [];
270+
}
176271
}

src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -195,11 +195,6 @@ internal static void ApplyDefaultValue(this JsonNode schema, object? defaultValu
195195
/// underlying schema generator does not support this, we need to manually apply the
196196
/// supported formats to the schemas associated with the generated type.
197197
///
198-
/// Whereas JsonSchema represents nullable types via `type: ["string", "null"]`, OpenAPI
199-
/// v3 exposes a nullable property on the schema. This method will set the nullable property
200-
/// based on whether the underlying schema generator returned an array type containing "null" to
201-
/// represent a nullable type or if the type was denoted as nullable from our lookup cache.
202-
///
203198
/// Note that this method targets <see cref="JsonNode"/> and not <see cref="OpenApiSchema"/> because
204199
/// it is is designed to be invoked via the `OnGenerated` callback in the underlying schema generator as
205200
/// opposed to after the generated schemas have been mapped to OpenAPI schemas.
@@ -349,8 +344,6 @@ internal static void ApplyParameterInfo(this JsonNode schema, ApiParameterDescri
349344
{
350345
schema.ApplyValidationAttributes(validationAttributes);
351346
}
352-
353-
schema.ApplyNullabilityContextInfo(parameterInfo);
354347
}
355348
// Route constraints are only defined on parameters that are sourced from the path. Since
356349
// 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
451444
}
452445

453446
/// <summary>
454-
/// Support applying nullability status for reference types provided as a parameter.
447+
/// Support applying nullability status for reference types provided as a property or field.
455448
/// </summary>
456449
/// <param name="schema">The <see cref="JsonNode"/> produced by the underlying schema generator.</param>
457-
/// <param name="parameterInfo">The <see cref="ParameterInfo" /> associated with the schema.</param>
458-
internal static void ApplyNullabilityContextInfo(this JsonNode schema, ParameterInfo parameterInfo)
450+
/// <param name="propertyInfo">The <see cref="JsonPropertyInfo" /> associated with the schema.</param>
451+
internal static void ApplyNullabilityContextInfo(this JsonNode schema, JsonPropertyInfo propertyInfo)
459452
{
460-
if (parameterInfo.ParameterType.IsValueType)
453+
// Avoid setting explicit nullability annotations for `object` types so they continue to match on the catch
454+
// all schema (no type, no format, no constraints).
455+
if (propertyInfo.PropertyType != typeof(object) && (propertyInfo.IsGetNullable || propertyInfo.IsSetNullable))
461456
{
462-
return;
457+
if (MapJsonNodeToSchemaType(schema[OpenApiSchemaKeywords.TypeKeyword]) is { } schemaTypes &&
458+
!schemaTypes.HasFlag(JsonSchemaType.Null))
459+
{
460+
schema[OpenApiSchemaKeywords.TypeKeyword] = (schemaTypes | JsonSchemaType.Null).ToString();
461+
}
463462
}
464-
465-
var nullabilityInfoContext = new NullabilityInfoContext();
466-
var nullabilityInfo = nullabilityInfoContext.Create(parameterInfo);
467-
if (nullabilityInfo.WriteState == NullabilityState.Nullable
468-
&& MapJsonNodeToSchemaType(schema[OpenApiSchemaKeywords.TypeKeyword]) is { } schemaTypes
469-
&& !schemaTypes.HasFlag(JsonSchemaType.Null))
463+
if (schema[OpenApiConstants.SchemaId] is not null &&
464+
propertyInfo.PropertyType != typeof(object) && propertyInfo.ShouldApplyNullablePropertySchema())
470465
{
471-
schema[OpenApiSchemaKeywords.TypeKeyword] = (schemaTypes | JsonSchemaType.Null).ToString();
466+
schema[OpenApiConstants.NullableProperty] = true;
472467
}
473468
}
474469

475470
/// <summary>
476-
/// Support applying nullability status for reference types provided as a property or field.
471+
/// Prunes the "null" type from the schema for types that are componentized. These
472+
/// types should represent their nullability using oneOf with null instead.
477473
/// </summary>
478474
/// <param name="schema">The <see cref="JsonNode"/> produced by the underlying schema generator.</param>
479-
/// <param name="propertyInfo">The <see cref="JsonPropertyInfo" /> associated with the schema.</param>
480-
internal static void ApplyNullabilityContextInfo(this JsonNode schema, JsonPropertyInfo propertyInfo)
475+
internal static void PruneNullTypeForComponentizedTypes(this JsonNode schema)
481476
{
482-
// Avoid setting explicit nullability annotations for `object` types so they continue to match on the catch
483-
// all schema (no type, no format, no constraints).
484-
if (propertyInfo.PropertyType != typeof(object) && (propertyInfo.IsGetNullable || propertyInfo.IsSetNullable))
477+
if (schema[OpenApiConstants.SchemaId] is not null &&
478+
schema[OpenApiSchemaKeywords.TypeKeyword] is JsonArray typeArray)
485479
{
486-
if (MapJsonNodeToSchemaType(schema[OpenApiSchemaKeywords.TypeKeyword]) is { } schemaTypes &&
487-
!schemaTypes.HasFlag(JsonSchemaType.Null))
480+
for (var i = typeArray.Count - 1; i >= 0; i--)
488481
{
489-
schema[OpenApiSchemaKeywords.TypeKeyword] = (schemaTypes | JsonSchemaType.Null).ToString();
482+
if (typeArray[i]?.GetValue<string>() == "null")
483+
{
484+
typeArray.RemoveAt(i);
485+
}
486+
}
487+
if (typeArray.Count == 1)
488+
{
489+
schema[OpenApiSchemaKeywords.TypeKeyword] = typeArray[0]?.GetValue<string>();
490490
}
491491
}
492492
}

src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,12 @@ internal static string GetSchemaReferenceId(this Type type, JsonSerializerOption
108108
return $"{typeName}Of{propertyNames}";
109109
}
110110

111+
// Special handling for nullable value types
112+
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>))
113+
{
114+
return type.GetGenericArguments()[0].GetSchemaReferenceId(options);
115+
}
116+
111117
// Special handling for generic types that are collections
112118
// Generic types become a concatenation of the generic type name and the type arguments
113119
if (type.IsGenericType)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.AspNetCore.OpenApi;
5+
6+
internal static class OpenApiSchemaExtensions
7+
{
8+
private static readonly OpenApiSchema _nullSchema = new() { Type = JsonSchemaType.Null };
9+
10+
public static IOpenApiSchema CreateOneOfNullableWrapper(this IOpenApiSchema originalSchema)
11+
{
12+
return new OpenApiSchema
13+
{
14+
OneOf =
15+
[
16+
_nullSchema,
17+
originalSchema
18+
]
19+
};
20+
}
21+
}

src/OpenApi/src/Extensions/TypeExtensions.cs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Linq;
5+
using System.Reflection;
6+
using System.Text.Json.Serialization.Metadata;
7+
using Microsoft.AspNetCore.Mvc.ApiExplorer;
8+
using Microsoft.AspNetCore.Mvc.Controllers;
9+
using Microsoft.AspNetCore.Mvc.Infrastructure;
10+
411
namespace Microsoft.AspNetCore.OpenApi;
512

613
internal static class TypeExtensions
@@ -30,4 +37,73 @@ public static bool IsJsonPatchDocument(this Type type)
3037

3138
return false;
3239
}
40+
41+
public static bool ShouldApplyNullableResponseSchema(this ApiResponseType apiResponseType, ApiDescription apiDescription)
42+
{
43+
// Get the MethodInfo from the ActionDescriptor
44+
var responseType = apiResponseType.Type;
45+
var methodInfo = apiDescription.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor
46+
? controllerActionDescriptor.MethodInfo
47+
: apiDescription.ActionDescriptor.EndpointMetadata.OfType<MethodInfo>().SingleOrDefault();
48+
49+
if (methodInfo is null)
50+
{
51+
return false;
52+
}
53+
54+
var returnType = methodInfo.ReturnType;
55+
if (returnType.IsGenericType &&
56+
(returnType.GetGenericTypeDefinition() == typeof(Task<>) || returnType.GetGenericTypeDefinition() == typeof(ValueTask<>)))
57+
{
58+
returnType = returnType.GetGenericArguments()[0];
59+
}
60+
if (returnType != responseType)
61+
{
62+
return false;
63+
}
64+
65+
if (returnType.IsValueType)
66+
{
67+
return apiResponseType.ModelMetadata?.IsNullableValueType ?? false;
68+
}
69+
70+
var nullabilityInfoContext = new NullabilityInfoContext();
71+
var nullabilityInfo = nullabilityInfoContext.Create(methodInfo.ReturnParameter);
72+
return nullabilityInfo.WriteState == NullabilityState.Nullable;
73+
}
74+
75+
public static bool ShouldApplyNullableRequestSchema(this ApiParameterDescription apiParameterDescription)
76+
{
77+
var parameterType = apiParameterDescription.Type;
78+
if (parameterType is null)
79+
{
80+
return false;
81+
}
82+
83+
if (apiParameterDescription.ParameterDescriptor is not IParameterInfoParameterDescriptor { ParameterInfo: { } parameterInfo })
84+
{
85+
return false;
86+
}
87+
88+
if (parameterType.IsValueType)
89+
{
90+
return apiParameterDescription.ModelMetadata?.IsNullableValueType ?? false;
91+
}
92+
93+
var nullabilityInfoContext = new NullabilityInfoContext();
94+
var nullabilityInfo = nullabilityInfoContext.Create(parameterInfo);
95+
return nullabilityInfo.WriteState == NullabilityState.Nullable;
96+
}
97+
98+
public static bool ShouldApplyNullablePropertySchema(this JsonPropertyInfo jsonPropertyInfo)
99+
{
100+
if (jsonPropertyInfo.AttributeProvider is not PropertyInfo propertyInfo)
101+
{
102+
return false;
103+
}
104+
105+
var nullabilityInfoContext = new NullabilityInfoContext();
106+
var nullabilityInfo = nullabilityInfoContext.Create(propertyInfo);
107+
return nullabilityInfo.WriteState == NullabilityState.Nullable;
108+
}
33109
}

src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,11 @@ public static void ReadProperty(ref Utf8JsonReader reader, string propertyName,
333333
schema.Metadata ??= new Dictionary<string, object>();
334334
schema.Metadata.Add(OpenApiConstants.SchemaId, reader.GetString() ?? string.Empty);
335335
break;
336+
case OpenApiConstants.NullableProperty:
337+
reader.Read();
338+
schema.Metadata ??= new Dictionary<string, object>();
339+
schema.Metadata.Add(OpenApiConstants.NullableProperty, reader.GetBoolean());
340+
break;
336341
// OpenAPI does not support the `const` keyword in its schema implementation, so
337342
// we map it to its closest approximation, an enum with a single value, here.
338343
case OpenApiSchemaKeywords.ConstKeyword:

src/OpenApi/src/Services/OpenApiConstants.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ internal static class OpenApiConstants
1717
internal const string RefExampleAnnotation = "x-ref-example";
1818
internal const string RefKeyword = "$ref";
1919
internal const string RefPrefix = "#";
20+
internal const string NullableProperty = "x-is-nullable-property";
2021
internal const string DefaultOpenApiResponseKey = "default";
2122
// Since there's a finite set of HTTP methods that can be included in a given
2223
// OpenApiPaths, we can pre-allocate an array of these methods and use a direct

src/OpenApi/src/Services/OpenApiDocumentService.cs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -423,8 +423,15 @@ private async Task<OpenApiResponse> GetResponseAsync(
423423
.Select(responseFormat => responseFormat.MediaType);
424424
foreach (var contentType in apiResponseFormatContentTypes)
425425
{
426-
var schema = apiResponseType.Type is { } type ? await _componentService.GetOrCreateSchemaAsync(document, type, scopedServiceProvider, schemaTransformers, null, cancellationToken) : new OpenApiSchema();
427-
response.Content[contentType] = new OpenApiMediaType { Schema = schema };
426+
IOpenApiSchema? schema = null;
427+
if (apiResponseType.Type is { } responseType)
428+
{
429+
schema = await _componentService.GetOrCreateSchemaAsync(document, responseType, scopedServiceProvider, schemaTransformers, null, cancellationToken);
430+
schema = apiResponseType.ShouldApplyNullableResponseSchema(apiDescription)
431+
? schema.CreateOneOfNullableWrapper()
432+
: schema;
433+
}
434+
response.Content[contentType] = new OpenApiMediaType { Schema = schema ?? new OpenApiSchema() };
428435
}
429436

430437
// MVC's `ProducesAttribute` doesn't implement the produces metadata that the ApiExplorer
@@ -744,7 +751,11 @@ private async Task<OpenApiRequestBody> GetJsonRequestBody(
744751
foreach (var requestFormat in supportedRequestFormats)
745752
{
746753
var contentType = requestFormat.MediaType;
747-
requestBody.Content[contentType] = new OpenApiMediaType { Schema = await _componentService.GetOrCreateSchemaAsync(document, bodyParameter.Type, scopedServiceProvider, schemaTransformers, bodyParameter, cancellationToken: cancellationToken) };
754+
var schema = await _componentService.GetOrCreateSchemaAsync(document, bodyParameter.Type, scopedServiceProvider, schemaTransformers, bodyParameter, cancellationToken: cancellationToken);
755+
schema = bodyParameter.ShouldApplyNullableRequestSchema()
756+
? schema.CreateOneOfNullableWrapper()
757+
: schema;
758+
requestBody.Content[contentType] = new OpenApiMediaType { Schema = schema };
748759
}
749760

750761
return requestBody;

0 commit comments

Comments
 (0)