Skip to content

Commit 71366d4

Browse files
committed
Use allOf for nullable properties
1 parent 0c90cc9 commit 71366d4

10 files changed

+93
-44
lines changed

src/OpenApi/src/Extensions/JsonNodeSchemaExtensions.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,30 @@ internal static void ApplyNullabilityContextInfo(this JsonNode schema, JsonPrope
460460
schema[OpenApiSchemaKeywords.TypeKeyword] = (schemaTypes | JsonSchemaType.Null).ToString();
461461
}
462462
}
463+
if (schema[OpenApiConstants.SchemaId] is not null &&
464+
propertyInfo.PropertyType != typeof(object) && propertyInfo.ShouldApplyNullablePropertySchema())
465+
{
466+
schema[OpenApiConstants.NullableProperty] = true;
467+
}
468+
}
469+
470+
internal static void PruneNullTypeForReferencedTypes(this JsonNode schema)
471+
{
472+
if (schema[OpenApiConstants.SchemaId] is not null &&
473+
schema[OpenApiSchemaKeywords.TypeKeyword] is JsonArray typeArray)
474+
{
475+
for (var i = typeArray.Count - 1; i >= 0; i--)
476+
{
477+
if (typeArray[i]?.GetValue<string>() == "null")
478+
{
479+
typeArray.RemoveAt(i);
480+
}
481+
}
482+
if (typeArray.Count == 1)
483+
{
484+
schema[OpenApiSchemaKeywords.TypeKeyword] = typeArray[0]?.GetValue<string>();
485+
}
486+
}
463487
}
464488

465489
private static JsonSchemaType? MapJsonNodeToSchemaType(JsonNode? jsonNode)

src/OpenApi/src/Extensions/TypeExtensions.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.Linq;
55
using System.Reflection;
6+
using System.Text.Json.Serialization.Metadata;
67
using Microsoft.AspNetCore.Mvc.ApiExplorer;
78
using Microsoft.AspNetCore.Mvc.Controllers;
89
using Microsoft.AspNetCore.Mvc.Infrastructure;
@@ -68,7 +69,6 @@ public static bool ShouldApplyNullableResponseSchema(this ApiResponseType apiRes
6869

6970
var nullabilityInfoContext = new NullabilityInfoContext();
7071
var nullabilityInfo = nullabilityInfoContext.Create(methodInfo.ReturnParameter);
71-
7272
return nullabilityInfo.WriteState == NullabilityState.Nullable;
7373
}
7474

@@ -92,7 +92,18 @@ public static bool ShouldApplyNullableRequestSchema(this ApiParameterDescription
9292

9393
var nullabilityInfoContext = new NullabilityInfoContext();
9494
var nullabilityInfo = nullabilityInfoContext.Create(parameterInfo);
95+
return nullabilityInfo.WriteState == NullabilityState.Nullable;
96+
}
9597

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);
96107
return nullabilityInfo.WriteState == NullabilityState.Nullable;
97108
}
98109
}

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/Schemas/OpenApiSchemaService.cs

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -97,21 +97,6 @@ internal sealed class OpenApiSchemaService(
9797
schema.ApplyPrimitiveTypesAndFormats(context, createSchemaReferenceId);
9898
schema.ApplySchemaReferenceId(context, createSchemaReferenceId);
9999
schema.MapPolymorphismOptionsToDiscriminator(context, createSchemaReferenceId);
100-
if (schema[OpenApiConstants.SchemaId] is { } schemaId && schema[OpenApiSchemaKeywords.TypeKeyword] is JsonArray typeArray)
101-
{
102-
// Remove null from union types when present
103-
for (var i = typeArray.Count - 1; i >= 0; i--)
104-
{
105-
if (typeArray[i]?.GetValue<string>() == "null")
106-
{
107-
typeArray.RemoveAt(i);
108-
}
109-
}
110-
if (typeArray.Count == 1)
111-
{
112-
schema[OpenApiSchemaKeywords.TypeKeyword] = typeArray[0]?.GetValue<string>();
113-
}
114-
}
115100
if (context.PropertyInfo is { } jsonPropertyInfo)
116101
{
117102
schema.ApplyNullabilityContextInfo(jsonPropertyInfo);
@@ -147,7 +132,7 @@ internal sealed class OpenApiSchemaService(
147132
}
148133
}
149134
}
150-
135+
schema.PruneNullTypeForReferencedTypes();
151136
return schema;
152137
}
153138
};
@@ -296,7 +281,17 @@ internal static IOpenApiSchema ResolveReferenceForSchema(OpenApiDocument documen
296281
{
297282
foreach (var property in schema.Properties)
298283
{
299-
schema.Properties[property.Key] = ResolveReferenceForSchema(document, property.Value, rootSchemaId);
284+
if (property.Value is OpenApiSchema targetSchema &&
285+
targetSchema.Metadata?.TryGetValue(OpenApiConstants.NullableProperty, out var isNullableProperty) == true &&
286+
isNullableProperty is true)
287+
{
288+
var resolvedProperty = ResolveReferenceForSchema(document, property.Value, rootSchemaId);
289+
schema.Properties[property.Key] = resolvedProperty.CreateAllOfNullableWrapper();
290+
}
291+
else
292+
{
293+
schema.Properties[property.Key] = ResolveReferenceForSchema(document, property.Value, rootSchemaId);
294+
}
300295
}
301296
}
302297

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,8 +1022,7 @@
10221022
"user": {
10231023
"$ref": "#/components/schemas/RefUser"
10241024
}
1025-
},
1026-
"nullable": true
1025+
}
10271026
},
10281027
"RefUser": {
10291028
"type": "object",
@@ -1124,7 +1123,14 @@
11241123
"$ref": "#/components/schemas/RefProfile"
11251124
},
11261125
"secondaryUser": {
1127-
"$ref": "#/components/schemas/RefProfile"
1126+
"allOf": [
1127+
{
1128+
"nullable": true
1129+
},
1130+
{
1131+
"$ref": "#/components/schemas/RefProfile"
1132+
}
1133+
]
11281134
}
11291135
}
11301136
},

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1027,10 +1027,7 @@
10271027
"required": [
10281028
"user"
10291029
],
1030-
"type": [
1031-
"null",
1032-
"object"
1033-
],
1030+
"type": "object",
10341031
"properties": {
10351032
"user": {
10361033
"$ref": "#/components/schemas/RefUser"
@@ -1136,7 +1133,14 @@
11361133
"$ref": "#/components/schemas/RefProfile"
11371134
},
11381135
"secondaryUser": {
1139-
"$ref": "#/components/schemas/RefProfile"
1136+
"allOf": [
1137+
{
1138+
"type": "null"
1139+
},
1140+
{
1141+
"$ref": "#/components/schemas/RefProfile"
1142+
}
1143+
]
11401144
}
11411145
}
11421146
},

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1840,10 +1840,7 @@
18401840
"required": [
18411841
"user"
18421842
],
1843-
"type": [
1844-
"null",
1845-
"object"
1846-
],
1843+
"type": "object",
18471844
"properties": {
18481845
"user": {
18491846
"$ref": "#/components/schemas/RefUser"
@@ -1949,7 +1946,14 @@
19491946
"$ref": "#/components/schemas/RefProfile"
19501947
},
19511948
"secondaryUser": {
1952-
"$ref": "#/components/schemas/RefProfile"
1949+
"allOf": [
1950+
{
1951+
"type": "null"
1952+
},
1953+
{
1954+
"$ref": "#/components/schemas/RefProfile"
1955+
}
1956+
]
19531957
}
19541958
}
19551959
},

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,7 @@ await VerifyOpenApiDocument(builder, document =>
510510
{
511511
Assert.Equal("value", property.Key);
512512
var propertyValue = property.Value;
513-
Assert.Equal(JsonSchemaType.Null | JsonSchemaType.Object, propertyValue.Type);
513+
Assert.Equal(JsonSchemaType.Object, propertyValue.Type);
514514
Assert.Collection(propertyValue.Properties,
515515
property =>
516516
{
@@ -536,7 +536,7 @@ await VerifyOpenApiDocument(builder, document =>
536536
{
537537
Assert.Equal("error", property.Key);
538538
var propertyValue = property.Value;
539-
Assert.Equal(JsonSchemaType.Null | JsonSchemaType.Object, propertyValue.Type);
539+
Assert.Equal(JsonSchemaType.Object, propertyValue.Type);
540540
Assert.Collection(propertyValue.Properties, property =>
541541
{
542542
Assert.Equal("code", property.Key);
@@ -624,7 +624,7 @@ await VerifyOpenApiDocument(builder, document =>
624624
{
625625
Assert.Equal("todo", property.Key);
626626
var propertyValue = property.Value;
627-
Assert.Equal(JsonSchemaType.Null | JsonSchemaType.Object, propertyValue.Type);
627+
Assert.Equal(JsonSchemaType.Object, propertyValue.Type);
628628
Assert.Collection(propertyValue.Properties,
629629
property =>
630630
{

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/Implementations/OpenApiSchemaReferenceTransformerTests.cs

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -501,10 +501,7 @@ await VerifyOpenApiDocument(builder, document =>
501501
serializedSchema = writer.ToString();
502502
Assert.Equal("""
503503
{
504-
"type": [
505-
"null",
506-
"object"
507-
],
504+
"type": "object",
508505
"properties": {
509506
"address": {
510507
"$ref": "#/components/schemas/AddressDto"
@@ -519,10 +516,7 @@ await VerifyOpenApiDocument(builder, document =>
519516
serializedSchema = writer.ToString();
520517
Assert.Equal("""
521518
{
522-
"type": [
523-
"null",
524-
"object"
525-
],
519+
"type": "object",
526520
"properties": {
527521
"relatedLocation": {
528522
"$ref": "#/components/schemas/LocationDto"
@@ -985,7 +979,10 @@ await VerifyOpenApiDocument(builder, document =>
985979

986980
// Check secondaryUser property (nullable RefProfile)
987981
var secondaryUserSchema = requestSchema.Properties!["secondaryUser"];
988-
Assert.Equal("RefProfile", ((OpenApiSchemaReference)secondaryUserSchema).Reference.Id);
982+
Assert.NotNull(secondaryUserSchema.AllOf);
983+
Assert.Collection(secondaryUserSchema.AllOf,
984+
item => Assert.Equal(JsonSchemaType.Null, item.Type),
985+
item => Assert.Equal("RefProfile", ((OpenApiSchemaReference)item).Reference.Id));
989986

990987
// Verify the RefProfile schema has a User property that references RefUser
991988
var userPropertySchema = primaryUserSchema.Properties!["user"];
@@ -998,10 +995,12 @@ await VerifyOpenApiDocument(builder, document =>
998995
Assert.Contains("email", userSchemaContent.Properties?.Keys ?? []);
999996

1000997
// Both properties should reference the same RefProfile schema
998+
var secondaryUserSchemaRef = secondaryUserSchema.AllOf.Last();
1001999
Assert.Equal(((OpenApiSchemaReference)primaryUserSchema).Reference.Id,
1002-
((OpenApiSchemaReference)secondaryUserSchema).Reference.Id);
1000+
((OpenApiSchemaReference)secondaryUserSchemaRef).Reference.Id);
10031001

10041002
Assert.Equal(["RefProfile", "RefUser", "Subscription"], document.Components!.Schemas!.Keys.OrderBy(x => x));
1003+
Assert.All(document.Components.Schemas.Values, item => Assert.False(item.Type?.HasFlag(JsonSchemaType.Null)));
10051004
});
10061005
}
10071006

0 commit comments

Comments
 (0)