Skip to content

Commit 0ace243

Browse files
authored
fix: handle nullability more accurately during serialization for 3.0/2.0 (#2933)
* Handle nullability more accurately * Cleanup * Rename SerializeTypeProperty to TrySerializeTypeProperty * Separate rp * Cleanup * Progress * Cleanp * Refactor * Use cached json extension * AddExtension only if Type is still unknown * Cleanup * Address review comments * Address review comments * Update OpenApiSchemaDeserializer.cs * Update OpenApiSchemaDeserializer.cs
1 parent 1aeb913 commit 0ace243

10 files changed

Lines changed: 201 additions & 225 deletions

File tree

src/Microsoft.OpenApi/Models/OpenApiSchema.cs

Lines changed: 34 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ namespace Microsoft.OpenApi
2121
/// </summary>
2222
public class OpenApiSchema : IOpenApiExtensible, IOpenApiSchema, IOpenApiSchemaMissingProperties, IOpenApiSchemaWithUnevaluatedProperties, IMetadataContainer
2323
{
24+
private static readonly IEnumerable<JsonNode> s_singleNullElementList = [ JsonNullSentinel.JsonNull ];
25+
2426
/// <inheritdoc />
2527
public string? Title { get; set; }
2628

@@ -109,13 +111,8 @@ public string? ExclusiveMinimum
109111
/// <inheritdoc />
110112
public JsonSchemaType? Type { get; set; }
111113

112-
// x-nullable is filtered out by deserializers, but keep the check here in case it gets added from user code.
113-
private bool IsNullable =>
114-
(Type.HasValue && Type.Value.HasFlag(JsonSchemaType.Null)) ||
115-
Extensions is not null &&
116-
Extensions.TryGetValue(OpenApiConstants.NullableExtension, out var nullExtRawValue) &&
117-
nullExtRawValue is JsonNodeExtension { Node: JsonNode jsonNode } &&
118-
jsonNode.GetValueKind() is JsonValueKind.True;
114+
private bool HasNullType
115+
=> Type.HasValue && Type.Value.HasFlag(JsonSchemaType.Null);
119116

120117
/// <inheritdoc />
121118
public string? Const { get; set; }
@@ -517,57 +514,30 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version
517514
: Enum;
518515
writer.WriteOptionalCollection(OpenApiConstants.Enum, enumValue, (nodeWriter, s) => nodeWriter.WriteAny(s));
519516

520-
// Handle oneOf/anyOf with null type for v3.0 downcast
521-
IList<IOpenApiSchema>? effectiveOneOf = OneOf;
522-
IList<IOpenApiSchema>? effectiveAnyOf = AnyOf;
523-
bool hasNullInComposition = false;
524-
bool hasOneOfNullAndSingleEnumWith3_0 = false;
525-
JsonSchemaType? inferredType = null;
526-
527517
if (version == OpenApiSpecVersion.OpenApi3_0)
528518
{
529-
(effectiveOneOf, var inferredOneOf, var nullInOneOf) = ProcessCompositionForNull(OneOf);
530-
hasNullInComposition |= nullInOneOf;
531-
inferredType = inferredOneOf ?? inferredType;
532-
(effectiveAnyOf, var inferredAnyOf, var nullInAnyOf) = ProcessCompositionForNull(AnyOf);
533-
hasNullInComposition |= nullInAnyOf;
534-
inferredType = inferredAnyOf ?? inferredType;
535-
536-
hasOneOfNullAndSingleEnumWith3_0 = nullInOneOf && effectiveOneOf is { Count: 1 } &&
537-
effectiveOneOf[0].Enum is { Count: > 0 };
519+
// If we have a schema that's only just { "type": "null" }, we serialize it as enum with null value.
520+
if (Type == JsonSchemaType.Null &&
521+
OneOf is not { Count: > 0 } &&
522+
AnyOf is not { Count: > 0 } &&
523+
AllOf is not { Count: > 0 } &&
524+
Enum is not { Count: > 0 })
525+
{
526+
writer.WriteOptionalCollection(OpenApiConstants.Enum, s_singleNullElementList, (nodeWriter, s) => nodeWriter.WriteAny(s));
527+
}
538528
}
539529

540530
// type
541-
SerializeTypeProperty(writer, version, inferredType);
531+
var serializedTypeProperty = TrySerializeTypeProperty(writer, version);
542532

543533
// allOf
544534
writer.WriteOptionalCollection(OpenApiConstants.AllOf, AllOf, callback);
545535

546536
// anyOf
547-
writer.WriteOptionalCollection(OpenApiConstants.AnyOf, effectiveAnyOf, callback);
537+
writer.WriteOptionalCollection(OpenApiConstants.AnyOf, AnyOf, callback);
548538

549539
// oneOf
550-
if (hasOneOfNullAndSingleEnumWith3_0)
551-
{
552-
writer.WriteRequiredCollection(OpenApiConstants.OneOf, effectiveOneOf!, (writer, element) =>
553-
{
554-
var clonedToMutateEnum = element.CreateShallowCopy();
555-
if (clonedToMutateEnum is OpenApiSchema { Enum: { } existingEnum } concreteCloned)
556-
{
557-
concreteCloned.Enum = [.. existingEnum, JsonNullSentinel.JsonNull];
558-
callback(writer, clonedToMutateEnum);
559-
}
560-
else
561-
{
562-
callback(writer, element);
563-
}
564-
});
565-
}
566-
else
567-
{
568-
writer.WriteOptionalCollection(OpenApiConstants.OneOf, effectiveOneOf, callback);
569-
}
570-
540+
writer.WriteOptionalCollection(OpenApiConstants.OneOf, OneOf, callback);
571541

572542
// not
573543
writer.WriteOptionalObject(OpenApiConstants.Not, Not, callback);
@@ -603,9 +573,14 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version
603573
writer.WriteOptionalObject(OpenApiConstants.Default, Default, (w, d) => w.WriteAny(d));
604574

605575
// nullable
606-
if (version == OpenApiSpecVersion.OpenApi3_0)
576+
if (version == OpenApiSpecVersion.OpenApi3_0 && serializedTypeProperty)
607577
{
608-
SerializeNullable(writer, version, hasNullInComposition);
578+
// https://spec.openapis.org/oas/v3.0.4.html#fixed-fields-20
579+
// This keyword only takes effect if type is explicitly defined within the same Schema Object.
580+
//
581+
// If the user explicitly set IsNullable to true, we serialize it even if redundant.
582+
// But if **we** are inferring it (from oneOf/anyOf), we don't serialize it when it's redundant.
583+
SerializeNullable(writer, version);
609584
}
610585

611586
// discriminator
@@ -832,7 +807,7 @@ private void SerializeAsV2(
832807
writer.WriteStartObject();
833808

834809
// type
835-
SerializeTypeProperty(writer, OpenApiSpecVersion.OpenApi2_0);
810+
TrySerializeTypeProperty(writer, OpenApiSpecVersion.OpenApi2_0);
836811

837812
// description
838813
writer.WriteProperty(OpenApiConstants.Description, Description);
@@ -995,31 +970,32 @@ private void SerializeAsV2(
995970
writer.WriteEndObject();
996971
}
997972

998-
private void SerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion version, JsonSchemaType? inferredType = null)
973+
private bool TrySerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion version, JsonSchemaType? inferredType = null)
999974
{
1000975
// Use original type or inferred type when the explicit type is not set
1001976
var typeToUse = Type ?? inferredType;
1002977

1003978
if (typeToUse is null)
1004979
{
1005-
return;
980+
return false;
1006981
}
1007982

1008-
var unifiedType = IsNullable ? typeToUse.Value | JsonSchemaType.Null : typeToUse.Value;
1009-
var typeWithoutNull = unifiedType & ~JsonSchemaType.Null;
1010-
1011983
switch (version)
1012984
{
1013985
case OpenApiSpecVersion.OpenApi2_0 or OpenApiSpecVersion.OpenApi3_0:
986+
var typeWithoutNull = typeToUse.Value & ~JsonSchemaType.Null;
1014987
if (typeWithoutNull != 0 && !HasMultipleTypes(typeWithoutNull))
1015988
{
1016989
writer.WriteProperty(OpenApiConstants.Type, typeWithoutNull.ToFirstIdentifier());
990+
return true;
1017991
}
1018992
break;
1019993
default:
1020-
WriteUnifiedSchemaType(unifiedType, writer);
1021-
break;
994+
WriteUnifiedSchemaType(typeToUse.Value, writer);
995+
return true;
1022996
}
997+
998+
return false;
1023999
}
10241000

10251001
private static bool IsPowerOfTwo(int x)
@@ -1054,9 +1030,9 @@ where type.HasFlag(flag)
10541030
}
10551031
}
10561032

1057-
private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version, bool hasNullInComposition = false)
1033+
private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version)
10581034
{
1059-
if (IsNullable || hasNullInComposition)
1035+
if (HasNullType)
10601036
{
10611037
switch (version)
10621038
{
@@ -1070,63 +1046,6 @@ private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version
10701046
}
10711047
}
10721048

1073-
/// <summary>
1074-
/// Processes a composition (oneOf or anyOf) for null types, filtering out null schemas and inferring common type.
1075-
/// </summary>
1076-
/// <param name="composition">The list of schemas in the composition.</param>
1077-
/// <returns>A tuple with the effective list, inferred type, and whether null is present in composition.</returns>
1078-
private static (IList<IOpenApiSchema>? effective, JsonSchemaType? inferredType, bool hasNullInComposition)
1079-
ProcessCompositionForNull(IList<IOpenApiSchema>? composition)
1080-
{
1081-
if (composition is null || !composition.Any(static s => s.Type is JsonSchemaType.Null))
1082-
{
1083-
// Nothing to patch
1084-
return (composition, null, false);
1085-
}
1086-
1087-
var nonNullSchemas = composition
1088-
.Where(static s => s.Type is null or not JsonSchemaType.Null)
1089-
.ToList();
1090-
1091-
if (nonNullSchemas.Count > 0)
1092-
{
1093-
JsonSchemaType commonType = 0;
1094-
1095-
foreach (var schema in nonNullSchemas)
1096-
{
1097-
if (schema.Type.HasValue)
1098-
{
1099-
commonType |= schema.Type.Value & ~JsonSchemaType.Null;
1100-
}
1101-
else if (schema.Enum is { Count: > 0 })
1102-
{
1103-
foreach (var enumValue in schema.Enum.Where(x => x is not null))
1104-
{
1105-
var currentType = enumValue.GetValueKind() switch
1106-
{
1107-
JsonValueKind.Array => JsonSchemaType.Array,
1108-
JsonValueKind.String => JsonSchemaType.String,
1109-
JsonValueKind.Number => JsonSchemaType.Number,
1110-
JsonValueKind.True or JsonValueKind.False => JsonSchemaType.Boolean,
1111-
JsonValueKind.Null => (JsonSchemaType)0,
1112-
_ => JsonSchemaType.Object,
1113-
};
1114-
1115-
commonType |= currentType;
1116-
}
1117-
1118-
commonType |= JsonSchemaType.String;
1119-
}
1120-
}
1121-
1122-
return (nonNullSchemas, commonType == 0 ? null : commonType, true);
1123-
}
1124-
else
1125-
{
1126-
return (null, null, true);
1127-
}
1128-
}
1129-
11301049
#if NET5_0_OR_GREATER
11311050
private static readonly Array jsonSchemaTypeValues = System.Enum.GetValues<JsonSchemaType>();
11321051
#else

src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
// Copyright (c) Microsoft Corporation. All rights reserved.
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT license.
33

4-
using System.Text.Json.Nodes;
5-
4+
using System;
65
using System.Collections.Generic;
76
using System.Globalization;
8-
using System;
97
using System.Linq;
8+
using System.Text.Json;
9+
using System.Text.Json.Nodes;
1010

1111
namespace Microsoft.OpenApi.Reader.V2
1212
{
@@ -16,6 +16,8 @@ namespace Microsoft.OpenApi.Reader.V2
1616
/// </summary>
1717
internal static partial class OpenApiV2Deserializer
1818
{
19+
private const string EncounteredNullableExtensionTrueMetadataKey = "encounteredNullable";
20+
1921
private static readonly FixedFieldMap<OpenApiSchema> _openApiSchemaFixedFields = new()
2022
{
2123
{
@@ -210,6 +212,29 @@ internal static partial class OpenApiV2Deserializer
210212
"default",
211213
(o, n, _, _) => o.Default = n
212214
},
215+
{
216+
OpenApiConstants.NullableExtension,
217+
(o, n, _, _) =>
218+
{
219+
if (bool.TryParse(n.GetScalarValue(), out var parsed) && parsed)
220+
{
221+
if (o.Type is not null && o.Type != 0)
222+
{
223+
o.Type |= JsonSchemaType.Null;
224+
}
225+
else
226+
{
227+
// We mirror the same behavior of OpenAPI 3.0 "nullable" which is a type modifier.
228+
// It only has effect if there is a "Type" set.
229+
// Given that we don't have a Type set yet, we want to keep track of it until the end of deserialization.
230+
// If, at a later point during deserialization, Type was set, we apply nullable to it.
231+
// Otherwise, we throw it away.
232+
o.Metadata ??= new Dictionary<string, object>();
233+
o.Metadata[EncounteredNullableExtensionTrueMetadataKey] = true;
234+
}
235+
}
236+
}
237+
},
213238
{
214239
"discriminator", (o, n, _, _) =>
215240
{
@@ -268,14 +293,13 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum
268293

269294
ParseMap(jsonObject, schema, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context);
270295

271-
if (schema.Extensions is not null && schema.Extensions.ContainsKey(OpenApiConstants.NullableExtension))
296+
if (schema.Metadata?.TryGetValue(EncounteredNullableExtensionTrueMetadataKey, out var value) == true)
272297
{
273-
if (schema.Type.HasValue)
298+
schema.Metadata.Remove(EncounteredNullableExtensionTrueMetadataKey);
299+
if (schema.Type is not null && schema.Type != 0 && value is bool isNullable && isNullable)
300+
{
274301
schema.Type |= JsonSchemaType.Null;
275-
else
276-
schema.Type = JsonSchemaType.Null;
277-
278-
schema.Extensions.Remove(OpenApiConstants.NullableExtension);
302+
}
279303
}
280304

281305
return schema;

src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
// Copyright (c) Microsoft Corporation. All rights reserved.
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT license.
33

4-
using System.Text.Json.Nodes;
5-
64
using System;
75
using System.Collections.Generic;
6+
using System.Data.SqlTypes;
87
using System.Globalization;
98
using System.Linq;
9+
using System.Text.Json;
10+
using System.Text.Json.Nodes;
1011

1112
namespace Microsoft.OpenApi.Reader.V3
1213
{
@@ -16,6 +17,8 @@ namespace Microsoft.OpenApi.Reader.V3
1617
/// </summary>
1718
internal static partial class OpenApiV3Deserializer
1819
{
20+
private const string EncounteredNullableTrueMetadataKey = "encounteredNullable";
21+
1922
private static readonly FixedFieldMap<OpenApiSchema> _openApiSchemaFixedFields = new()
2023
{
2124
{
@@ -222,10 +225,20 @@ internal static partial class OpenApiV3Deserializer
222225
{
223226
if (bool.TryParse(n.GetScalarValue(), out var parsed) && parsed)
224227
{
225-
if (o.Type.HasValue)
228+
if (o.Type is not null && o.Type != 0)
229+
{
226230
o.Type |= JsonSchemaType.Null;
231+
}
227232
else
228-
o.Type = JsonSchemaType.Null;
233+
{
234+
// "nullable" is a type modifier.
235+
// It only has effect if there is a "Type" set.
236+
// Given that we don't have a Type set yet, we want to keep track of it until the end of deserialization.
237+
// If, at a later point during deserialization, Type was set, we apply nullable to it.
238+
// Otherwise, we throw it away.
239+
o.Metadata ??= new Dictionary<string, object>();
240+
o.Metadata[EncounteredNullableTrueMetadataKey] = true;
241+
}
229242
}
230243
}
231244
},
@@ -385,14 +398,13 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum
385398

386399
ParseMap(jsonObject, schema, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context);
387400

388-
if (schema.Extensions is not null && schema.Extensions.ContainsKey(OpenApiConstants.NullableExtension))
401+
if (schema.Metadata?.TryGetValue(EncounteredNullableTrueMetadataKey, out var value) == true)
389402
{
390-
if (schema.Type.HasValue)
403+
schema.Metadata.Remove(EncounteredNullableTrueMetadataKey);
404+
if (schema.Type is not null && schema.Type != 0 && value is bool isNullable && isNullable)
405+
{
391406
schema.Type |= JsonSchemaType.Null;
392-
else
393-
schema.Type = JsonSchemaType.Null;
394-
395-
schema.Extensions.Remove(OpenApiConstants.NullableExtension);
407+
}
396408
}
397409

398410
return schema;

0 commit comments

Comments
 (0)