Skip to content

Commit 310b6e2

Browse files
Youssef1313baywet
authored andcommitted
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 557bd6a commit 310b6e2

8 files changed

Lines changed: 189 additions & 192 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; }
@@ -512,57 +509,30 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version
512509
: Enum;
513510
writer.WriteOptionalCollection(OpenApiConstants.Enum, enumValue, (nodeWriter, s) => nodeWriter.WriteAny(s));
514511

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

535525
// type
536-
SerializeTypeProperty(writer, version, inferredType);
526+
var serializedTypeProperty = TrySerializeTypeProperty(writer, version);
537527

538528
// allOf
539529
writer.WriteOptionalCollection(OpenApiConstants.AllOf, AllOf, callback);
540530

541531
// anyOf
542-
writer.WriteOptionalCollection(OpenApiConstants.AnyOf, effectiveAnyOf, callback);
532+
writer.WriteOptionalCollection(OpenApiConstants.AnyOf, AnyOf, callback);
543533

544534
// oneOf
545-
if (hasOneOfNullAndSingleEnumWith3_0)
546-
{
547-
writer.WriteRequiredCollection(OpenApiConstants.OneOf, effectiveOneOf!, (writer, element) =>
548-
{
549-
var clonedToMutateEnum = element.CreateShallowCopy();
550-
if (clonedToMutateEnum is OpenApiSchema { Enum: { } existingEnum } concreteCloned)
551-
{
552-
concreteCloned.Enum = [.. existingEnum, JsonNullSentinel.JsonNull];
553-
callback(writer, clonedToMutateEnum);
554-
}
555-
else
556-
{
557-
callback(writer, element);
558-
}
559-
});
560-
}
561-
else
562-
{
563-
writer.WriteOptionalCollection(OpenApiConstants.OneOf, effectiveOneOf, callback);
564-
}
565-
535+
writer.WriteOptionalCollection(OpenApiConstants.OneOf, OneOf, callback);
566536

567537
// not
568538
writer.WriteOptionalObject(OpenApiConstants.Not, Not, callback);
@@ -598,9 +568,14 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version
598568
writer.WriteOptionalObject(OpenApiConstants.Default, Default, (w, d) => w.WriteAny(d));
599569

600570
// nullable
601-
if (version == OpenApiSpecVersion.OpenApi3_0)
571+
if (version == OpenApiSpecVersion.OpenApi3_0 && serializedTypeProperty)
602572
{
603-
SerializeNullable(writer, version, hasNullInComposition);
573+
// https://spec.openapis.org/oas/v3.0.4.html#fixed-fields-20
574+
// This keyword only takes effect if type is explicitly defined within the same Schema Object.
575+
//
576+
// If the user explicitly set IsNullable to true, we serialize it even if redundant.
577+
// But if **we** are inferring it (from oneOf/anyOf), we don't serialize it when it's redundant.
578+
SerializeNullable(writer, version);
604579
}
605580

606581
// discriminator
@@ -827,7 +802,7 @@ private void SerializeAsV2(
827802
writer.WriteStartObject();
828803

829804
// type
830-
SerializeTypeProperty(writer, OpenApiSpecVersion.OpenApi2_0);
805+
TrySerializeTypeProperty(writer, OpenApiSpecVersion.OpenApi2_0);
831806

832807
// description
833808
writer.WriteProperty(OpenApiConstants.Description, Description);
@@ -990,31 +965,32 @@ private void SerializeAsV2(
990965
writer.WriteEndObject();
991966
}
992967

993-
private void SerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion version, JsonSchemaType? inferredType = null)
968+
private bool TrySerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion version, JsonSchemaType? inferredType = null)
994969
{
995970
// Use original type or inferred type when the explicit type is not set
996971
var typeToUse = Type ?? inferredType;
997972

998973
if (typeToUse is null)
999974
{
1000-
return;
975+
return false;
1001976
}
1002977

1003-
var unifiedType = IsNullable ? typeToUse.Value | JsonSchemaType.Null : typeToUse.Value;
1004-
var typeWithoutNull = unifiedType & ~JsonSchemaType.Null;
1005-
1006978
switch (version)
1007979
{
1008980
case OpenApiSpecVersion.OpenApi2_0 or OpenApiSpecVersion.OpenApi3_0:
981+
var typeWithoutNull = typeToUse.Value & ~JsonSchemaType.Null;
1009982
if (typeWithoutNull != 0 && !HasMultipleTypes(typeWithoutNull))
1010983
{
1011984
writer.WriteProperty(OpenApiConstants.Type, typeWithoutNull.ToFirstIdentifier());
985+
return true;
1012986
}
1013987
break;
1014988
default:
1015-
WriteUnifiedSchemaType(unifiedType, writer);
1016-
break;
989+
WriteUnifiedSchemaType(typeToUse.Value, writer);
990+
return true;
1017991
}
992+
993+
return false;
1018994
}
1019995

1020996
private static bool IsPowerOfTwo(int x)
@@ -1049,9 +1025,9 @@ where type.HasFlag(flag)
10491025
}
10501026
}
10511027

1052-
private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version, bool hasNullInComposition = false)
1028+
private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version)
10531029
{
1054-
if (IsNullable || hasNullInComposition)
1030+
if (HasNullType)
10551031
{
10561032
switch (version)
10571033
{
@@ -1065,63 +1041,6 @@ private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version
10651041
}
10661042
}
10671043

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