From a70df439f724263c0bee69b90839773ec13844ec Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Thu, 9 Jul 2026 12:28:25 +0200 Subject: [PATCH 01/12] Handle nullability more accurately --- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 85 +++++++++---------- .../Reader/V2/OpenApiSchemaDeserializer.cs | 22 ++--- .../Reader/V3/OpenApiSchemaDeserializer.cs | 28 +++--- .../Reader/V31/OpenApiSchemaDeserializer.cs | 24 +++--- .../Reader/V32/OpenApiSchemaDeserializer.cs | 24 +++--- .../V3Tests/OpenApiSchemaTests.cs | 5 +- .../Models/OpenApiSchemaTests.cs | 85 +++++++++++++++++-- 7 files changed, 167 insertions(+), 106 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index 4d41a6e0e..44d4a1ae4 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -21,6 +21,8 @@ namespace Microsoft.OpenApi /// public class OpenApiSchema : IOpenApiExtensible, IOpenApiSchema, IOpenApiSchemaMissingProperties, IOpenApiSchemaWithUnevaluatedProperties, IMetadataContainer { + private static readonly IEnumerable s_singleNullElementList = [ JsonNullSentinel.JsonNull ]; + /// public string? Title { get; set; } @@ -110,12 +112,17 @@ public string? ExclusiveMinimum public JsonSchemaType? Type { get; set; } // x-nullable is filtered out by deserializers, but keep the check here in case it gets added from user code. - private bool IsNullable => - (Type.HasValue && Type.Value.HasFlag(JsonSchemaType.Null)) || - Extensions is not null && - Extensions.TryGetValue(OpenApiConstants.NullableExtension, out var nullExtRawValue) && - nullExtRawValue is JsonNodeExtension { Node: JsonNode jsonNode } && - jsonNode.GetValueKind() is JsonValueKind.True; + internal bool IsNullable + { + get => field || HasNullableExtension || (Type.HasValue && Type.Value.HasFlag(JsonSchemaType.Null)); + set => field = value; + } + + private bool HasNullableExtension + => Extensions is not null && + Extensions.TryGetValue(OpenApiConstants.NullableExtension, out var nullExtRawValue) && + nullExtRawValue is JsonNodeExtension { Node: JsonNode jsonNode } && + jsonNode.GetValueKind() is JsonValueKind.True; /// public string? Const { get; set; } @@ -518,56 +525,36 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version writer.WriteOptionalCollection(OpenApiConstants.Enum, enumValue, (nodeWriter, s) => nodeWriter.WriteAny(s)); // Handle oneOf/anyOf with null type for v3.0 downcast - IList? effectiveOneOf = OneOf; - IList? effectiveAnyOf = AnyOf; bool hasNullInComposition = false; - bool hasOneOfNullAndSingleEnumWith3_0 = false; JsonSchemaType? inferredType = null; if (version == OpenApiSpecVersion.OpenApi3_0) { - (effectiveOneOf, var inferredOneOf, var nullInOneOf) = ProcessCompositionForNull(OneOf); + (var inferredOneOf, var nullInOneOf) = ProcessCompositionForNull(OneOf); hasNullInComposition |= nullInOneOf; inferredType = inferredOneOf ?? inferredType; - (effectiveAnyOf, var inferredAnyOf, var nullInAnyOf) = ProcessCompositionForNull(AnyOf); + (var inferredAnyOf, var nullInAnyOf) = ProcessCompositionForNull(AnyOf); hasNullInComposition |= nullInAnyOf; inferredType = inferredAnyOf ?? inferredType; - hasOneOfNullAndSingleEnumWith3_0 = nullInOneOf && effectiveOneOf is { Count: 1 } && - effectiveOneOf[0].Enum is { Count: > 0 }; + // If we have a schema that's only just { "type": "null" }, we serialize it as enum with null value. + if (Type == JsonSchemaType.Null && OneOf is not { Count: > 0 } && AnyOf is not { Count: > 0 }) + { + writer.WriteOptionalCollection(OpenApiConstants.Enum, s_singleNullElementList, (nodeWriter, s) => nodeWriter.WriteAny(s)); + } } // type - SerializeTypeProperty(writer, version, inferredType); + var serializedTypeProperty = SerializeTypeProperty(writer, version, inferredType); // allOf writer.WriteOptionalCollection(OpenApiConstants.AllOf, AllOf, callback); // anyOf - writer.WriteOptionalCollection(OpenApiConstants.AnyOf, effectiveAnyOf, callback); + writer.WriteOptionalCollection(OpenApiConstants.AnyOf, AnyOf, callback); // oneOf - if (hasOneOfNullAndSingleEnumWith3_0) - { - writer.WriteRequiredCollection(OpenApiConstants.OneOf, effectiveOneOf!, (writer, element) => - { - var clonedToMutateEnum = element.CreateShallowCopy(); - if (clonedToMutateEnum is OpenApiSchema { Enum: { } existingEnum } concreteCloned) - { - concreteCloned.Enum = [.. existingEnum, JsonNullSentinel.JsonNull]; - callback(writer, clonedToMutateEnum); - } - else - { - callback(writer, element); - } - }); - } - else - { - writer.WriteOptionalCollection(OpenApiConstants.OneOf, effectiveOneOf, callback); - } - + writer.WriteOptionalCollection(OpenApiConstants.OneOf, OneOf, callback); // not writer.WriteOptionalObject(OpenApiConstants.Not, Not, callback); @@ -603,8 +590,13 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version writer.WriteOptionalObject(OpenApiConstants.Default, Default, (w, d) => w.WriteAny(d)); // nullable - if (version == OpenApiSpecVersion.OpenApi3_0) + if (version == OpenApiSpecVersion.OpenApi3_0 && serializedTypeProperty) { + // https://spec.openapis.org/oas/v3.0.4.html#fixed-fields-20 + // This keyword only takes effect if type is explicitly defined within the same Schema Object. + // + // If the user explicitly set IsNullable to true, we serialize it even if redundant. + // But if **we** are inferring it (from oneOf/anyOf), we don't serialize it when it's redundant. SerializeNullable(writer, version, hasNullInComposition); } @@ -995,14 +987,14 @@ private void SerializeAsV2( writer.WriteEndObject(); } - private void SerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion version, JsonSchemaType? inferredType = null) + private bool SerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion version, JsonSchemaType? inferredType = null) { // Use original type or inferred type when the explicit type is not set var typeToUse = Type ?? inferredType; if (typeToUse is null) { - return; + return false; } var unifiedType = IsNullable ? typeToUse.Value | JsonSchemaType.Null : typeToUse.Value; @@ -1014,12 +1006,15 @@ private void SerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion ver if (typeWithoutNull != 0 && !HasMultipleTypes(typeWithoutNull)) { writer.WriteProperty(OpenApiConstants.Type, typeWithoutNull.ToFirstIdentifier()); + return true; } break; default: WriteUnifiedSchemaType(unifiedType, writer); - break; + return true; } + + return false; } private static bool IsPowerOfTwo(int x) @@ -1075,13 +1070,13 @@ private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version /// /// The list of schemas in the composition. /// A tuple with the effective list, inferred type, and whether null is present in composition. - private static (IList? effective, JsonSchemaType? inferredType, bool hasNullInComposition) + private static (JsonSchemaType? inferredType, bool hasNullInComposition) ProcessCompositionForNull(IList? composition) { if (composition is null || !composition.Any(static s => s.Type is JsonSchemaType.Null)) { // Nothing to patch - return (composition, null, false); + return (null, false); } var nonNullSchemas = composition @@ -1114,16 +1109,14 @@ private static (IList? effective, JsonSchemaType? inferredType, commonType |= currentType; } - - commonType |= JsonSchemaType.String; } } - return (nonNullSchemas, commonType == 0 ? null : commonType, true); + return (commonType == 0 ? null : commonType, true); } else { - return (null, null, true); + return (null, true); } } diff --git a/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs index 421468750..ea5a69c1a 100644 --- a/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs @@ -1,12 +1,12 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. -using System.Text.Json.Nodes; - +using System; using System.Collections.Generic; using System.Globalization; -using System; using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; namespace Microsoft.OpenApi.Reader.V2 { @@ -268,16 +268,18 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum ParseMap(jsonObject, schema, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context); - if (schema.Extensions is not null && schema.Extensions.ContainsKey(OpenApiConstants.NullableExtension)) + if (schema.Extensions is not null && schema.Extensions.TryGetValue(OpenApiConstants.NullableExtension, out var nullableExtension)) { - if (schema.Type.HasValue) - schema.Type |= JsonSchemaType.Null; - else - schema.Type = JsonSchemaType.Null; - + var isNullable = nullableExtension is JsonNodeExtension { Node: JsonNode jsonNode } && jsonNode.GetValueKind() is JsonValueKind.True; + schema.IsNullable = isNullable; schema.Extensions.Remove(OpenApiConstants.NullableExtension); } + if (schema.IsNullable && schema.Type is not null && schema.Type != 0) + { + schema.Type |= JsonSchemaType.Null; + } + return schema; } } diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs index 6a933fe5f..a15d553a6 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs @@ -1,12 +1,13 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. -using System.Text.Json.Nodes; - using System; using System.Collections.Generic; +using System.Data.SqlTypes; using System.Globalization; using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; namespace Microsoft.OpenApi.Reader.V3 { @@ -16,6 +17,8 @@ namespace Microsoft.OpenApi.Reader.V3 /// internal static partial class OpenApiV3Deserializer { + private static readonly IOpenApiExtension _nullableTrueExtension = new JsonNodeExtension(JsonValue.Create(true)); + private static readonly FixedFieldMap _openApiSchemaFixedFields = new() { { @@ -222,10 +225,7 @@ internal static partial class OpenApiV3Deserializer { if (bool.TryParse(n.GetScalarValue(), out var parsed) && parsed) { - if (o.Type.HasValue) - o.Type |= JsonSchemaType.Null; - else - o.Type = JsonSchemaType.Null; + o.IsNullable = true; } } }, @@ -385,16 +385,18 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum ParseMap(jsonObject, schema, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context); - if (schema.Extensions is not null && schema.Extensions.ContainsKey(OpenApiConstants.NullableExtension)) + if (schema.Extensions is not null && schema.Extensions.TryGetValue(OpenApiConstants.NullableExtension, out var nullableExtension)) { - if (schema.Type.HasValue) - schema.Type |= JsonSchemaType.Null; - else - schema.Type = JsonSchemaType.Null; - + var isNullable = nullableExtension is JsonNodeExtension { Node: JsonNode jsonNode } && jsonNode.GetValueKind() is JsonValueKind.True; + schema.IsNullable = isNullable; schema.Extensions.Remove(OpenApiConstants.NullableExtension); } + if (schema.IsNullable && schema.Type is not null && schema.Type != 0) + { + schema.Type |= JsonSchemaType.Null; + } + return schema; } } diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs index e060537b2..8006cf1db 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Text.Json; using System.Text.Json.Nodes; namespace Microsoft.OpenApi.Reader.V31; @@ -332,14 +333,7 @@ internal static partial class OpenApiV31Deserializer var value = n.GetScalarValue(); if (value is not null) { - var nullable = bool.Parse(value); - if (nullable) // if nullable, convert type into an array of type(s) and null - { - if (o.Type.HasValue) - o.Type |= JsonSchemaType.Null; - else - o.Type = JsonSchemaType.Null; - } + o.IsNullable = bool.Parse(value); } } }, @@ -476,16 +470,18 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum schema.UnrecognizedKeywords[name] = value; }); - if (schema.Extensions is not null && schema.Extensions.ContainsKey(OpenApiConstants.NullableExtension)) + if (schema.Extensions is not null && schema.Extensions.TryGetValue(OpenApiConstants.NullableExtension, out var nullableExtension)) { - if (schema.Type.HasValue) - schema.Type |= JsonSchemaType.Null; - else - schema.Type = JsonSchemaType.Null; - + var isNullable = nullableExtension is JsonNodeExtension { Node: JsonNode jsonNode } && jsonNode.GetValueKind() is JsonValueKind.True; + schema.IsNullable = isNullable; schema.Extensions.Remove(OpenApiConstants.NullableExtension); } + if (schema.IsNullable && schema.Type is not null && schema.Type != 0) + { + schema.Type |= JsonSchemaType.Null; + } + if (!string.IsNullOrEmpty(identifier) && hostDocument.Workspace is not null) { // register the schema in our registry using the identifier's URL diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs index 71a422516..bcf455bda 100644 --- a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Text.Json; using System.Text.Json.Nodes; namespace Microsoft.OpenApi.Reader.V32; @@ -332,14 +333,7 @@ internal static partial class OpenApiV32Deserializer var value = n.GetScalarValue(); if (value is not null) { - var nullable = bool.Parse(value); - if (nullable) // if nullable, convert type into an array of type(s) and null - { - if (o.Type.HasValue) - o.Type |= JsonSchemaType.Null; - else - o.Type = JsonSchemaType.Null; - } + o.IsNullable = bool.Parse(value); } } }, @@ -476,16 +470,18 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum schema.UnrecognizedKeywords[name] = value; }); - if (schema.Extensions is not null && schema.Extensions.ContainsKey(OpenApiConstants.NullableExtension)) + if (schema.Extensions is not null && schema.Extensions.TryGetValue(OpenApiConstants.NullableExtension, out var nullableExtension)) { - if (schema.Type.HasValue) - schema.Type |= JsonSchemaType.Null; - else - schema.Type = JsonSchemaType.Null; - + var isNullable = nullableExtension is JsonNodeExtension { Node: JsonNode jsonNode } && jsonNode.GetValueKind() is JsonValueKind.True; + schema.IsNullable = isNullable; schema.Extensions.Remove(OpenApiConstants.NullableExtension); } + if (schema.IsNullable && schema.Type is not null && schema.Type != 0) + { + schema.Type |= JsonSchemaType.Null; + } + if (!string.IsNullOrEmpty(identifier) && hostDocument.Workspace is not null) { // register the schema in our registry using the identifier's URL diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs index 4e8b80286..cda5c4c72 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System.Collections.Generic; @@ -532,8 +532,9 @@ public async Task SerializeSchemaWithNullableShouldSucceed() [Fact] public async Task SerializeSchemaWithOnlyNullableShouldSucceed() { + // NOTE: nullable property has no effect on the schema if type is not specified, so it is omitted in the serialized output. // Arrange - var expected = @"nullable: true"; + var expected = @"{ }"; var path = Path.Combine(SampleFolderPath, "schemaWithOnlyNullable.yaml"); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index b3048db00..de0842c62 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -961,6 +961,11 @@ public async Task SerializeOneOfWithNullAsV3ShouldUseNullableAsync() { "type": "string", "oneOf": [ + { + "enum": [ + null + ] + }, { "maxLength": 10, "type": "string" @@ -1001,14 +1006,18 @@ public async Task SerializeOneOfWithNullAndMultipleSchemasAsV3ShouldMarkItAsNull """ { "oneOf": [ + { + "enum": [ + null + ] + }, { "type": "string" }, { "type": "number" } - ], - "nullable": true + ] } """; @@ -1050,6 +1059,11 @@ public async Task SerializeAnyOfWithNullAsV3ShouldUseNullableAsync() { "type": "object", "anyOf": [ + { + "enum": [ + null + ] + }, { "type": "object", "properties": { @@ -1093,6 +1107,11 @@ public async Task SerializeAnyOfWithNullAndMultipleSchemasAsV3ShouldApplyNullabl """ { "anyOf": [ + { + "enum": [ + null + ] + }, { "minLength": 1, "type": "string" @@ -1100,8 +1119,7 @@ public async Task SerializeAnyOfWithNullAndMultipleSchemasAsV3ShouldApplyNullabl { "type": "integer" } - ], - "nullable": true + ] } """; @@ -1133,7 +1151,13 @@ public async Task SerializeOneOfWithOnlyNullAsV3ShouldJustBeNullableAsync() var expectedV3Schema = """ { - "nullable": true + "oneOf": [ + { + "enum": [ + null + ] + } + ] } """; @@ -1232,6 +1256,11 @@ public async Task SerializeOneOfWithNullAndRefAsV3ShouldUseNullableAsync() { "type": "object", "oneOf": [ + { + "enum": [ + null + ] + }, { "$ref": "#/components/schemas/Pet" } @@ -1874,10 +1903,14 @@ public async Task SerializeNullableEnumWith3_0() "oneOf": [ { "enum": [ - "A", - "B", null ] + }, + { + "enum": [ + "A", + "B" + ] } ], "nullable": true @@ -1912,6 +1945,37 @@ public async Task SerializeNullableEnumWith3_1_And_Later(OpenApiSpecVersion vers Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); } + [Fact] + public async Task SerializeNullableTypeWith3_0() + { + var schema = CreateTypeNullSchema(); + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var expected = """ + { + "enum": [ + null + ] + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + } + + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi3_1)] + [InlineData(OpenApiSpecVersion.OpenApi3_2)] + public async Task SerializeNullableTypeWith3_1_And_Later(OpenApiSpecVersion version) + { + var schema = CreateTypeNullSchema(); + var result = await schema.SerializeAsJsonAsync(version); + var expected = """ + { + "type": "null" + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + } + private OpenApiSchema CreateNullableEnumSchema() { var schema = new OpenApiSchema(); @@ -1928,6 +1992,13 @@ private OpenApiSchema CreateNullableEnumSchema() return schema; } + private OpenApiSchema CreateTypeNullSchema() + { + var schema = new OpenApiSchema(); + schema.Type = JsonSchemaType.Null; + return schema; + } + internal class SchemaVisitor : OpenApiVisitorBase { public List Titles = new(); From f66b887ed78d35c7c46b6a652a3b2ad47852fcdc Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Thu, 9 Jul 2026 14:56:03 +0200 Subject: [PATCH 02/12] Cleanup --- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 18 ++++++++++++++++-- .../Reader/V2/OpenApiSchemaDeserializer.cs | 12 +----------- .../Reader/V3/OpenApiSchemaDeserializer.cs | 12 +----------- .../Reader/V31/OpenApiSchemaDeserializer.cs | 12 +----------- .../Reader/V32/OpenApiSchemaDeserializer.cs | 12 +----------- 5 files changed, 20 insertions(+), 46 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index 44d4a1ae4..ba2be0ee1 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -114,11 +114,11 @@ public string? ExclusiveMinimum // x-nullable is filtered out by deserializers, but keep the check here in case it gets added from user code. internal bool IsNullable { - get => field || HasNullableExtension || (Type.HasValue && Type.Value.HasFlag(JsonSchemaType.Null)); + get => field || HasTrueNullableExtension || (Type.HasValue && Type.Value.HasFlag(JsonSchemaType.Null)); set => field = value; } - private bool HasNullableExtension + private bool HasTrueNullableExtension => Extensions is not null && Extensions.TryGetValue(OpenApiConstants.NullableExtension, out var nullExtRawValue) && nullExtRawValue is JsonNodeExtension { Node: JsonNode jsonNode } && @@ -794,6 +794,20 @@ internal void WriteAsItemsProperties(IOpenApiWriter writer) writer.WriteExtensions(Extensions, OpenApiSpecVersion.OpenApi2_0); } + internal void FinalizeDeserialization() + { + if (HasTrueNullableExtension) + { + IsNullable = true; + Extensions!.Remove(OpenApiConstants.NullableExtension); + } + + if (IsNullable && Type is not null && Type != 0) + { + Type |= JsonSchemaType.Null; + } + } + private void WriteFormatProperty(IOpenApiWriter writer) { var formatToWrite = Format; diff --git a/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs index ea5a69c1a..3cb797796 100644 --- a/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs @@ -268,17 +268,7 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum ParseMap(jsonObject, schema, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context); - if (schema.Extensions is not null && schema.Extensions.TryGetValue(OpenApiConstants.NullableExtension, out var nullableExtension)) - { - var isNullable = nullableExtension is JsonNodeExtension { Node: JsonNode jsonNode } && jsonNode.GetValueKind() is JsonValueKind.True; - schema.IsNullable = isNullable; - schema.Extensions.Remove(OpenApiConstants.NullableExtension); - } - - if (schema.IsNullable && schema.Type is not null && schema.Type != 0) - { - schema.Type |= JsonSchemaType.Null; - } + schema.FinalizeDeserialization(); return schema; } diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs index a15d553a6..3a7b68a17 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs @@ -385,17 +385,7 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum ParseMap(jsonObject, schema, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context); - if (schema.Extensions is not null && schema.Extensions.TryGetValue(OpenApiConstants.NullableExtension, out var nullableExtension)) - { - var isNullable = nullableExtension is JsonNodeExtension { Node: JsonNode jsonNode } && jsonNode.GetValueKind() is JsonValueKind.True; - schema.IsNullable = isNullable; - schema.Extensions.Remove(OpenApiConstants.NullableExtension); - } - - if (schema.IsNullable && schema.Type is not null && schema.Type != 0) - { - schema.Type |= JsonSchemaType.Null; - } + schema.FinalizeDeserialization(); return schema; } diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs index 8006cf1db..2f4d9a6ce 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs @@ -470,17 +470,7 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum schema.UnrecognizedKeywords[name] = value; }); - if (schema.Extensions is not null && schema.Extensions.TryGetValue(OpenApiConstants.NullableExtension, out var nullableExtension)) - { - var isNullable = nullableExtension is JsonNodeExtension { Node: JsonNode jsonNode } && jsonNode.GetValueKind() is JsonValueKind.True; - schema.IsNullable = isNullable; - schema.Extensions.Remove(OpenApiConstants.NullableExtension); - } - - if (schema.IsNullable && schema.Type is not null && schema.Type != 0) - { - schema.Type |= JsonSchemaType.Null; - } + schema.FinalizeDeserialization(); if (!string.IsNullOrEmpty(identifier) && hostDocument.Workspace is not null) { diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs index bcf455bda..45d97674b 100644 --- a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs @@ -470,17 +470,7 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum schema.UnrecognizedKeywords[name] = value; }); - if (schema.Extensions is not null && schema.Extensions.TryGetValue(OpenApiConstants.NullableExtension, out var nullableExtension)) - { - var isNullable = nullableExtension is JsonNodeExtension { Node: JsonNode jsonNode } && jsonNode.GetValueKind() is JsonValueKind.True; - schema.IsNullable = isNullable; - schema.Extensions.Remove(OpenApiConstants.NullableExtension); - } - - if (schema.IsNullable && schema.Type is not null && schema.Type != 0) - { - schema.Type |= JsonSchemaType.Null; - } + schema.FinalizeDeserialization(); if (!string.IsNullOrEmpty(identifier) && hostDocument.Workspace is not null) { From 157397e0fbd19bdbd97e960d3aaf3fca09ab35c5 Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Thu, 9 Jul 2026 15:02:05 +0200 Subject: [PATCH 03/12] Rename SerializeTypeProperty to TrySerializeTypeProperty --- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index ba2be0ee1..320427609 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -545,7 +545,7 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version } // type - var serializedTypeProperty = SerializeTypeProperty(writer, version, inferredType); + var serializedTypeProperty = TrySerializeTypeProperty(writer, version, inferredType); // allOf writer.WriteOptionalCollection(OpenApiConstants.AllOf, AllOf, callback); @@ -838,7 +838,7 @@ private void SerializeAsV2( writer.WriteStartObject(); // type - SerializeTypeProperty(writer, OpenApiSpecVersion.OpenApi2_0); + TrySerializeTypeProperty(writer, OpenApiSpecVersion.OpenApi2_0); // description writer.WriteProperty(OpenApiConstants.Description, Description); @@ -1001,7 +1001,7 @@ private void SerializeAsV2( writer.WriteEndObject(); } - private bool SerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion version, JsonSchemaType? inferredType = null) + private bool TrySerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion version, JsonSchemaType? inferredType = null) { // Use original type or inferred type when the explicit type is not set var typeToUse = Type ?? inferredType; From eb2baed21c829ae9cc48b3862a1006b131ee44aa Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Thu, 9 Jul 2026 15:50:47 +0200 Subject: [PATCH 04/12] Separate rp --- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 20 ++++++++++++------- .../Reader/V3/OpenApiSchemaDeserializer.cs | 2 +- .../Reader/V31/OpenApiSchemaDeserializer.cs | 2 +- .../Reader/V32/OpenApiSchemaDeserializer.cs | 2 +- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index 320427609..36cba9e75 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -111,12 +111,18 @@ public string? ExclusiveMinimum /// public JsonSchemaType? Type { get; set; } + /// + /// This property is internal and is only used to mark the "nullable" state when doing deserialization. + /// It's only set during deserialization to flag whether we encountered "nullable" or not. + /// And it's read: + /// 1. During FinalizeDeserialization to determine whether the Type should be changed to include null or not. + /// 2. When serializing nullable, if IsNullable was false and IsNullableFromDeserialization was true, we will still serialize "nullable". + /// + internal bool IsNullableFromDeserialization { get; set; } + // x-nullable is filtered out by deserializers, but keep the check here in case it gets added from user code. internal bool IsNullable - { - get => field || HasTrueNullableExtension || (Type.HasValue && Type.Value.HasFlag(JsonSchemaType.Null)); - set => field = value; - } + => HasTrueNullableExtension || (Type.HasValue && Type.Value.HasFlag(JsonSchemaType.Null)); private bool HasTrueNullableExtension => Extensions is not null && @@ -798,11 +804,11 @@ internal void FinalizeDeserialization() { if (HasTrueNullableExtension) { - IsNullable = true; Extensions!.Remove(OpenApiConstants.NullableExtension); + IsNullableFromDeserialization = true; } - if (IsNullable && Type is not null && Type != 0) + if (IsNullableFromDeserialization && Type is not null && Type != 0) { Type |= JsonSchemaType.Null; } @@ -1065,7 +1071,7 @@ where type.HasFlag(flag) private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version, bool hasNullInComposition = false) { - if (IsNullable || hasNullInComposition) + if (IsNullable || IsNullableFromDeserialization || hasNullInComposition) { switch (version) { diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs index 3a7b68a17..c2496de87 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs @@ -225,7 +225,7 @@ internal static partial class OpenApiV3Deserializer { if (bool.TryParse(n.GetScalarValue(), out var parsed) && parsed) { - o.IsNullable = true; + o.IsNullableFromDeserialization = true; } } }, diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs index 2f4d9a6ce..a2b62ea0e 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs @@ -333,7 +333,7 @@ internal static partial class OpenApiV31Deserializer var value = n.GetScalarValue(); if (value is not null) { - o.IsNullable = bool.Parse(value); + o.IsNullableFromDeserialization = bool.Parse(value); } } }, diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs index 45d97674b..ab2c99c89 100644 --- a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs @@ -333,7 +333,7 @@ internal static partial class OpenApiV32Deserializer var value = n.GetScalarValue(); if (value is not null) { - o.IsNullable = bool.Parse(value); + o.IsNullableFromDeserialization = bool.Parse(value); } } }, From 8db16f9a2029e4fddb413c72e3e6f07a7832cbfc Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Thu, 9 Jul 2026 16:13:46 +0200 Subject: [PATCH 05/12] Cleanup --- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index 36cba9e75..c76a24cb0 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -1017,12 +1017,10 @@ private bool TrySerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion return false; } - var unifiedType = IsNullable ? typeToUse.Value | JsonSchemaType.Null : typeToUse.Value; - var typeWithoutNull = unifiedType & ~JsonSchemaType.Null; - switch (version) { case OpenApiSpecVersion.OpenApi2_0 or OpenApiSpecVersion.OpenApi3_0: + var typeWithoutNull = typeToUse.Value & ~JsonSchemaType.Null; if (typeWithoutNull != 0 && !HasMultipleTypes(typeWithoutNull)) { writer.WriteProperty(OpenApiConstants.Type, typeWithoutNull.ToFirstIdentifier()); @@ -1030,7 +1028,7 @@ private bool TrySerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion } break; default: - WriteUnifiedSchemaType(unifiedType, writer); + WriteUnifiedSchemaType(IsNullable ? typeToUse.Value | JsonSchemaType.Null : typeToUse.Value, writer); return true; } From 01105d1e058feb2baa0484353a93c0d564366f00 Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Thu, 9 Jul 2026 22:04:52 +0200 Subject: [PATCH 06/12] Progress --- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 11 +++++++---- .../Reader/V2/OpenApiSchemaDeserializer.cs | 2 +- .../Reader/V3/OpenApiSchemaDeserializer.cs | 2 +- .../Reader/V31/OpenApiSchemaDeserializer.cs | 13 +------------ .../Reader/V32/OpenApiSchemaDeserializer.cs | 13 +------------ .../V31Tests/OpenApiSchemaTests.cs | 14 +++++++++----- .../V32Tests/OpenApiSchemaTests.cs | 15 ++++++++++----- 7 files changed, 30 insertions(+), 40 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index c76a24cb0..f3c56bf5a 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -800,12 +800,15 @@ internal void WriteAsItemsProperties(IOpenApiWriter writer) writer.WriteExtensions(Extensions, OpenApiSpecVersion.OpenApi2_0); } - internal void FinalizeDeserialization() + internal void FinalizeDeserialization(OpenApiSpecVersion version) { - if (HasTrueNullableExtension) + if (version is OpenApiSpecVersion.OpenApi2_0) { - Extensions!.Remove(OpenApiConstants.NullableExtension); - IsNullableFromDeserialization = true; + if (HasTrueNullableExtension) + { + Extensions!.Remove(OpenApiConstants.NullableExtension); + IsNullableFromDeserialization = true; + } } if (IsNullableFromDeserialization && Type is not null && Type != 0) diff --git a/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs index 3cb797796..91ff63430 100644 --- a/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs @@ -268,7 +268,7 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum ParseMap(jsonObject, schema, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context); - schema.FinalizeDeserialization(); + schema.FinalizeDeserialization(OpenApiSpecVersion.OpenApi2_0); return schema; } diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs index c2496de87..45899d535 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs @@ -385,7 +385,7 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum ParseMap(jsonObject, schema, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context); - schema.FinalizeDeserialization(); + schema.FinalizeDeserialization(OpenApiSpecVersion.OpenApi3_0); return schema; } diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs index a2b62ea0e..d40899686 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs @@ -326,17 +326,6 @@ internal static partial class OpenApiV31Deserializer "default", (o, n, _, _) => o.Default = n }, - { - "nullable", - (o, n, _, _) => - { - var value = n.GetScalarValue(); - if (value is not null) - { - o.IsNullableFromDeserialization = bool.Parse(value); - } - } - }, { "discriminator", (o, n, doc, c) => o.Discriminator = LoadDiscriminator(n, doc, c) @@ -470,7 +459,7 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum schema.UnrecognizedKeywords[name] = value; }); - schema.FinalizeDeserialization(); + schema.FinalizeDeserialization(OpenApiSpecVersion.OpenApi3_1); if (!string.IsNullOrEmpty(identifier) && hostDocument.Workspace is not null) { diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs index ab2c99c89..ed2ce4628 100644 --- a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs @@ -326,17 +326,6 @@ internal static partial class OpenApiV32Deserializer "default", (o, n, _, _) => o.Default = n }, - { - "nullable", - (o, n, _, _) => - { - var value = n.GetScalarValue(); - if (value is not null) - { - o.IsNullableFromDeserialization = bool.Parse(value); - } - } - }, { "discriminator", (o, n, doc, c) => o.Discriminator = LoadDiscriminator(n, doc, c) @@ -470,7 +459,7 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum schema.UnrecognizedKeywords[name] = value; }); - schema.FinalizeDeserialization(); + schema.FinalizeDeserialization(OpenApiSpecVersion.OpenApi3_2); if (!string.IsNullOrEmpty(identifier) && hostDocument.Workspace is not null) { diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs index 5cf558f7c..cc994c0e6 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs @@ -141,13 +141,15 @@ public void ParseSchemaWithTypeArrayWorks() [Theory] [InlineData(@"{ ""nullable"": true, ""type"": ""string"" }")] [InlineData(@"{ ""type"": ""string"", ""nullable"": true }")] - public void ParseSchemaWithNullableBeforeOrAfterTypePreservesNullFlag(string schemaJson) + public void ParseSchemaWithNullableBeforeOrAfterTypeDoesNotPreserveNullFlag(string schemaJson) { + // "nullable" is only for 3.0. + // Act var schema = OpenApiModelFactory.Parse(schemaJson, OpenApiSpecVersion.OpenApi3_1, new(), out _, "json", SettingsFixture.ReaderSettings); // Assert - Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, schema.Type); + Assert.Equal(JsonSchemaType.String, schema.Type); } [Fact] @@ -517,7 +519,7 @@ public void SerializeSchemaWithTypeArrayAndNullableDoesntEmitType() - ""int"" nullable: true"; - var expected = @"x-nullable: true"; + var expected = @"{ }"; var schema = OpenApiModelFactory.Parse(input, OpenApiSpecVersion.OpenApi3_1, new(), out _, "yaml", SettingsFixture.ReaderSettings); @@ -531,8 +533,10 @@ public void SerializeSchemaWithTypeArrayAndNullableDoesntEmitType() [Theory] [InlineData("schemaWithNullable.yaml")] [InlineData("schemaWithNullableExtension.yaml")] - public async Task LoadSchemaWithNullableExtensionAsV31Works(string filePath) + public async Task LoadSchemaWithNullableExtensionAsV31ShouldNotWork(string filePath) { + // "nullable" is only for 3.0. + // and "x-nullable" is only for 2.0. // Arrange var path = Path.Combine(SampleFolderPath, filePath); @@ -540,7 +544,7 @@ public async Task LoadSchemaWithNullableExtensionAsV31Works(string filePath) var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_1, new(), SettingsFixture.ReaderSettings); // Assert - Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, schema.Type); + Assert.Equal(JsonSchemaType.String, schema.Type); } [Fact] diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs index a56850cde..5b294837d 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs @@ -140,13 +140,15 @@ public void ParseSchemaWithTypeArrayWorks() [Theory] [InlineData(@"{ ""nullable"": true, ""type"": ""string"" }")] [InlineData(@"{ ""type"": ""string"", ""nullable"": true }")] - public void ParseSchemaWithNullableBeforeOrAfterTypePreservesNullFlag(string schemaJson) + public void ParseSchemaWithNullableBeforeOrAfterTypeDoesNotPreserveNullFlag(string schemaJson) { + // "nullable" is only for 3.0. + // Act var schema = OpenApiModelFactory.Parse(schemaJson, OpenApiSpecVersion.OpenApi3_2, new(), out _, "json", SettingsFixture.ReaderSettings); // Assert - Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, schema.Type); + Assert.Equal(JsonSchemaType.String, schema.Type); } [Fact] @@ -419,7 +421,7 @@ public void SerializeSchemaWithTypeArrayAndNullableDoesntEmitType() - ""int"" nullable: true"; - var expected = @"x-nullable: true"; + var expected = @"{ }"; var schema = OpenApiModelFactory.Parse(input, OpenApiSpecVersion.OpenApi3_2, new(), out _, "yaml", SettingsFixture.ReaderSettings); @@ -433,8 +435,11 @@ public void SerializeSchemaWithTypeArrayAndNullableDoesntEmitType() [Theory] [InlineData("schemaWithNullable.yaml")] [InlineData("schemaWithNullableExtension.yaml")] - public async Task LoadSchemaWithNullableExtensionAsV32Works(string filePath) + public async Task LoadSchemaWithNullableExtensionAsV32ShouldNotWork(string filePath) { + // "nullable" is only for 3.0. + // and "x-nullable" is only for 2.0. + // Arrange var path = Path.Combine(SampleFolderPath, filePath); @@ -442,7 +447,7 @@ public async Task LoadSchemaWithNullableExtensionAsV32Works(string filePath) var schema = await OpenApiModelFactory.LoadAsync(path, OpenApiSpecVersion.OpenApi3_2, new(), SettingsFixture.ReaderSettings); // Assert - Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, schema.Type); + Assert.Equal(JsonSchemaType.String, schema.Type); } [Fact] From 7ce25f772021e01e5a9f81ff2602668fef80ce63 Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Thu, 9 Jul 2026 22:24:20 +0200 Subject: [PATCH 07/12] Cleanp --- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 30 +++++++------------ .../Reader/V3/OpenApiSchemaDeserializer.cs | 2 +- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index f3c56bf5a..84fa7861d 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -111,19 +111,12 @@ public string? ExclusiveMinimum /// public JsonSchemaType? Type { get; set; } - /// - /// This property is internal and is only used to mark the "nullable" state when doing deserialization. - /// It's only set during deserialization to flag whether we encountered "nullable" or not. - /// And it's read: - /// 1. During FinalizeDeserialization to determine whether the Type should be changed to include null or not. - /// 2. When serializing nullable, if IsNullable was false and IsNullableFromDeserialization was true, we will still serialize "nullable". - /// - internal bool IsNullableFromDeserialization { get; set; } + internal bool Nullable { get; set; } - // x-nullable is filtered out by deserializers, but keep the check here in case it gets added from user code. - internal bool IsNullable - => HasTrueNullableExtension || (Type.HasValue && Type.Value.HasFlag(JsonSchemaType.Null)); + private bool NullableOrHasNullType + => Nullable || (Type.HasValue && Type.Value.HasFlag(JsonSchemaType.Null)); + // x-nullable is filtered out by deserializers, but keep the check here in case it gets added from user code. private bool HasTrueNullableExtension => Extensions is not null && Extensions.TryGetValue(OpenApiConstants.NullableExtension, out var nullExtRawValue) && @@ -802,16 +795,13 @@ internal void WriteAsItemsProperties(IOpenApiWriter writer) internal void FinalizeDeserialization(OpenApiSpecVersion version) { - if (version is OpenApiSpecVersion.OpenApi2_0) + if (version is OpenApiSpecVersion.OpenApi2_0 && HasTrueNullableExtension) { - if (HasTrueNullableExtension) - { - Extensions!.Remove(OpenApiConstants.NullableExtension); - IsNullableFromDeserialization = true; - } + Extensions!.Remove(OpenApiConstants.NullableExtension); + Nullable = true; } - if (IsNullableFromDeserialization && Type is not null && Type != 0) + if (Nullable && Type is not null && Type != 0) { Type |= JsonSchemaType.Null; } @@ -1031,7 +1021,7 @@ private bool TrySerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion } break; default: - WriteUnifiedSchemaType(IsNullable ? typeToUse.Value | JsonSchemaType.Null : typeToUse.Value, writer); + WriteUnifiedSchemaType(typeToUse.Value, writer); return true; } @@ -1072,7 +1062,7 @@ where type.HasFlag(flag) private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version, bool hasNullInComposition = false) { - if (IsNullable || IsNullableFromDeserialization || hasNullInComposition) + if (NullableOrHasNullType || hasNullInComposition) { switch (version) { diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs index 45899d535..2098444fb 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs @@ -225,7 +225,7 @@ internal static partial class OpenApiV3Deserializer { if (bool.TryParse(n.GetScalarValue(), out var parsed) && parsed) { - o.IsNullableFromDeserialization = true; + o.Nullable = true; } } }, From 9b24b1753bc21ff5b76f2cdd836ab36a41607245 Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Thu, 9 Jul 2026 23:01:32 +0200 Subject: [PATCH 08/12] Refactor --- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 21 +++++++------------ .../Reader/V3/OpenApiSchemaDeserializer.cs | 6 +++++- .../V2Tests/OpenApiSchemaTests.cs | 6 ++++-- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index 84fa7861d..c196fded9 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -111,12 +111,9 @@ public string? ExclusiveMinimum /// public JsonSchemaType? Type { get; set; } - internal bool Nullable { get; set; } + private bool HasNullType + => Type.HasValue && Type.Value.HasFlag(JsonSchemaType.Null); - private bool NullableOrHasNullType - => Nullable || (Type.HasValue && Type.Value.HasFlag(JsonSchemaType.Null)); - - // x-nullable is filtered out by deserializers, but keep the check here in case it gets added from user code. private bool HasTrueNullableExtension => Extensions is not null && Extensions.TryGetValue(OpenApiConstants.NullableExtension, out var nullExtRawValue) && @@ -795,15 +792,13 @@ internal void WriteAsItemsProperties(IOpenApiWriter writer) internal void FinalizeDeserialization(OpenApiSpecVersion version) { - if (version is OpenApiSpecVersion.OpenApi2_0 && HasTrueNullableExtension) + if (version is OpenApiSpecVersion.OpenApi2_0 or OpenApiSpecVersion.OpenApi3_0 && HasTrueNullableExtension) { Extensions!.Remove(OpenApiConstants.NullableExtension); - Nullable = true; - } - - if (Nullable && Type is not null && Type != 0) - { - Type |= JsonSchemaType.Null; + if (Type is not null && Type != 0) + { + Type |= JsonSchemaType.Null; + } } } @@ -1062,7 +1057,7 @@ where type.HasFlag(flag) private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version, bool hasNullInComposition = false) { - if (NullableOrHasNullType || hasNullInComposition) + if (HasNullType || hasNullInComposition) { switch (version) { diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs index 2098444fb..b9075aae9 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs @@ -225,7 +225,11 @@ internal static partial class OpenApiV3Deserializer { if (bool.TryParse(n.GetScalarValue(), out var parsed) && parsed) { - o.Nullable = true; + // While we are dealing with v3 document, we are still using x-nullable extension here. + // This is used only as a marker during deserialization to indicate that the schema is nullable. + // When we do FinalizeDeserialization, we will modify the OpenApiSchema.Type to + // include JsonSchemaType.Null (only if needed), and always remove the extension. + o.AddExtension(OpenApiConstants.NullableExtension, new JsonNodeExtension(JsonValue.Create(true))); } } }, diff --git a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSchemaTests.cs index 291e3978a..5822d2239 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSchemaTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System.Collections.Generic; @@ -173,8 +173,10 @@ public async Task SerializeSchemaWithNullableShouldSucceed() [Fact] public async Task SerializeSchemaWithOnlyNullableShouldSucceed() { + // NOTE: x-nullable extension has no effect on the schema if type is not specified, so it is omitted in the serialized output. + // Arrange - var expected = @"x-nullable: true"; + var expected = @"{ }"; var path = Path.Combine(SampleFolderPath, "schemaWithOnlyNullableExtension.yaml"); From e75425029709465ad98432671571d62c72b012ea Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Thu, 9 Jul 2026 23:29:58 +0200 Subject: [PATCH 09/12] Use cached json extension --- src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs index b9075aae9..5844645bf 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs @@ -229,7 +229,7 @@ internal static partial class OpenApiV3Deserializer // This is used only as a marker during deserialization to indicate that the schema is nullable. // When we do FinalizeDeserialization, we will modify the OpenApiSchema.Type to // include JsonSchemaType.Null (only if needed), and always remove the extension. - o.AddExtension(OpenApiConstants.NullableExtension, new JsonNodeExtension(JsonValue.Create(true))); + o.AddExtension(OpenApiConstants.NullableExtension, _nullableTrueExtension); } } }, From 4b74290278787d43ddbe9cbfe51eab6d9ab6d3f6 Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Thu, 9 Jul 2026 23:54:30 +0200 Subject: [PATCH 10/12] AddExtension only if Type is still unknown --- .../Reader/V3/OpenApiSchemaDeserializer.cs | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs index 5844645bf..7f52fbd7a 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs @@ -225,11 +225,22 @@ internal static partial class OpenApiV3Deserializer { if (bool.TryParse(n.GetScalarValue(), out var parsed) && parsed) { - // While we are dealing with v3 document, we are still using x-nullable extension here. - // This is used only as a marker during deserialization to indicate that the schema is nullable. - // When we do FinalizeDeserialization, we will modify the OpenApiSchema.Type to - // include JsonSchemaType.Null (only if needed), and always remove the extension. - o.AddExtension(OpenApiConstants.NullableExtension, _nullableTrueExtension); + if (o.Type is not null && o.Type != 0) + { + o.Type |= JsonSchemaType.Null; + } + else + { + // While we are dealing with v3 document, we are still using x-nullable extension here. + // This is used only as a marker during deserialization to indicate that the schema is nullable. + // When we do FinalizeDeserialization, we will modify the OpenApiSchema.Type to + // include JsonSchemaType.Null (only if needed), and always remove the extension. + // The reason is that "nullable" property should only take effect if "Type" is set. + // If we knew Type is set, we add "Null" to it. + // Otherwise, it might be that it will be set later. + // In this case, we add the marker extension, and check at the end of deserialization, and remove the extension. + o.AddExtension(OpenApiConstants.NullableExtension, _nullableTrueExtension); + } } } }, From fd32d81504d1cd30292a1cae11ee914c6496e13d Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Fri, 10 Jul 2026 08:56:47 +0200 Subject: [PATCH 11/12] Cleanup --- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 18 ------------------ .../Reader/V2/OpenApiSchemaDeserializer.cs | 11 ++++++++++- .../Reader/V3/OpenApiSchemaDeserializer.cs | 18 +++++++++++++++--- .../Reader/V31/OpenApiSchemaDeserializer.cs | 2 -- .../Reader/V32/OpenApiSchemaDeserializer.cs | 2 -- 5 files changed, 25 insertions(+), 26 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index c196fded9..e01ee2115 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -114,12 +114,6 @@ public string? ExclusiveMinimum private bool HasNullType => Type.HasValue && Type.Value.HasFlag(JsonSchemaType.Null); - private bool HasTrueNullableExtension - => Extensions is not null && - Extensions.TryGetValue(OpenApiConstants.NullableExtension, out var nullExtRawValue) && - nullExtRawValue is JsonNodeExtension { Node: JsonNode jsonNode } && - jsonNode.GetValueKind() is JsonValueKind.True; - /// public string? Const { get; set; } @@ -790,18 +784,6 @@ internal void WriteAsItemsProperties(IOpenApiWriter writer) writer.WriteExtensions(Extensions, OpenApiSpecVersion.OpenApi2_0); } - internal void FinalizeDeserialization(OpenApiSpecVersion version) - { - if (version is OpenApiSpecVersion.OpenApi2_0 or OpenApiSpecVersion.OpenApi3_0 && HasTrueNullableExtension) - { - Extensions!.Remove(OpenApiConstants.NullableExtension); - if (Type is not null && Type != 0) - { - Type |= JsonSchemaType.Null; - } - } - } - private void WriteFormatProperty(IOpenApiWriter writer) { var formatToWrite = Format; diff --git a/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs index 91ff63430..005aa51ea 100644 --- a/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs @@ -268,7 +268,16 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum ParseMap(jsonObject, schema, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context); - schema.FinalizeDeserialization(OpenApiSpecVersion.OpenApi2_0); + var extensions = schema.Extensions; + if (extensions?.TryGetValue(OpenApiConstants.NullableExtension, out var nullExtRawValue) == true && + nullExtRawValue is JsonNodeExtension { Node: JsonNode jsonNode }) + { + extensions.Remove(OpenApiConstants.NullableExtension); + if (schema.Type is not null && schema.Type != 0 && jsonNode.GetValueKind() is JsonValueKind.True) + { + schema.Type |= JsonSchemaType.Null; + } + } return schema; } diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs index 7f52fbd7a..303997e7b 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs @@ -233,8 +233,8 @@ internal static partial class OpenApiV3Deserializer { // While we are dealing with v3 document, we are still using x-nullable extension here. // This is used only as a marker during deserialization to indicate that the schema is nullable. - // When we do FinalizeDeserialization, we will modify the OpenApiSchema.Type to - // include JsonSchemaType.Null (only if needed), and always remove the extension. + // When we complete the deserialization, we will modify the OpenApiSchema.Type to + // include JsonSchemaType.Null (**only if** needed), and always remove the extension. // The reason is that "nullable" property should only take effect if "Type" is set. // If we knew Type is set, we add "Null" to it. // Otherwise, it might be that it will be set later. @@ -400,7 +400,19 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum ParseMap(jsonObject, schema, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context); - schema.FinalizeDeserialization(OpenApiSpecVersion.OpenApi3_0); + if (schema.Extensions?.TryGetValue(OpenApiConstants.NullableExtension, out var value) == true && + value == _nullableTrueExtension) + { + // If this is "our own" internal _nullableTrueExtension instance, then we know we encountered "nullable": true in the schema + // at a point where Type wasn't set yet. + // We check now if schema.Type is set. + // If it is, we add JsonSchemaType.Null to it. + schema.Extensions.Remove(OpenApiConstants.NullableExtension); + if (schema.Type is not null && schema.Type != 0) + { + schema.Type |= JsonSchemaType.Null; + } + } return schema; } diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs index d40899686..c14237f83 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs @@ -459,8 +459,6 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum schema.UnrecognizedKeywords[name] = value; }); - schema.FinalizeDeserialization(OpenApiSpecVersion.OpenApi3_1); - if (!string.IsNullOrEmpty(identifier) && hostDocument.Workspace is not null) { // register the schema in our registry using the identifier's URL diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs index ed2ce4628..892822a45 100644 --- a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs @@ -459,8 +459,6 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum schema.UnrecognizedKeywords[name] = value; }); - schema.FinalizeDeserialization(OpenApiSpecVersion.OpenApi3_2); - if (!string.IsNullOrEmpty(identifier) && hostDocument.Workspace is not null) { // register the schema in our registry using the identifier's URL From 2176b0ba20c9aa9a80b8faddea7d0cabbb648f06 Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Fri, 10 Jul 2026 18:12:34 +0200 Subject: [PATCH 12/12] Address review comments --- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 76 ++----------------- .../Reader/V3/OpenApiSchemaDeserializer.cs | 31 +++----- .../Models/OpenApiSchemaTests.cs | 16 +--- 3 files changed, 20 insertions(+), 103 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index e01ee2115..02a47a68e 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -514,28 +514,17 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version : Enum; writer.WriteOptionalCollection(OpenApiConstants.Enum, enumValue, (nodeWriter, s) => nodeWriter.WriteAny(s)); - // Handle oneOf/anyOf with null type for v3.0 downcast - bool hasNullInComposition = false; - JsonSchemaType? inferredType = null; - if (version == OpenApiSpecVersion.OpenApi3_0) { - (var inferredOneOf, var nullInOneOf) = ProcessCompositionForNull(OneOf); - hasNullInComposition |= nullInOneOf; - inferredType = inferredOneOf ?? inferredType; - (var inferredAnyOf, var nullInAnyOf) = ProcessCompositionForNull(AnyOf); - hasNullInComposition |= nullInAnyOf; - inferredType = inferredAnyOf ?? inferredType; - // If we have a schema that's only just { "type": "null" }, we serialize it as enum with null value. - if (Type == JsonSchemaType.Null && OneOf is not { Count: > 0 } && AnyOf is not { Count: > 0 }) + if (Type == JsonSchemaType.Null && OneOf is not { Count: > 0 } && AnyOf is not { Count: > 0 } && Enum is not { Count: > 0 }) { writer.WriteOptionalCollection(OpenApiConstants.Enum, s_singleNullElementList, (nodeWriter, s) => nodeWriter.WriteAny(s)); } } // type - var serializedTypeProperty = TrySerializeTypeProperty(writer, version, inferredType); + var serializedTypeProperty = TrySerializeTypeProperty(writer, version); // allOf writer.WriteOptionalCollection(OpenApiConstants.AllOf, AllOf, callback); @@ -587,7 +576,7 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version // // If the user explicitly set IsNullable to true, we serialize it even if redundant. // But if **we** are inferring it (from oneOf/anyOf), we don't serialize it when it's redundant. - SerializeNullable(writer, version, hasNullInComposition); + SerializeNullable(writer, version); } // discriminator @@ -1037,9 +1026,9 @@ where type.HasFlag(flag) } } - private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version, bool hasNullInComposition = false) + private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version) { - if (HasNullType || hasNullInComposition) + if (HasNullType) { switch (version) { @@ -1053,61 +1042,6 @@ private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version } } - /// - /// Processes a composition (oneOf or anyOf) for null types, filtering out null schemas and inferring common type. - /// - /// The list of schemas in the composition. - /// A tuple with the effective list, inferred type, and whether null is present in composition. - private static (JsonSchemaType? inferredType, bool hasNullInComposition) - ProcessCompositionForNull(IList? composition) - { - if (composition is null || !composition.Any(static s => s.Type is JsonSchemaType.Null)) - { - // Nothing to patch - return (null, false); - } - - var nonNullSchemas = composition - .Where(static s => s.Type is null or not JsonSchemaType.Null) - .ToList(); - - if (nonNullSchemas.Count > 0) - { - JsonSchemaType commonType = 0; - - foreach (var schema in nonNullSchemas) - { - if (schema.Type.HasValue) - { - commonType |= schema.Type.Value & ~JsonSchemaType.Null; - } - else if (schema.Enum is { Count: > 0 }) - { - foreach (var enumValue in schema.Enum.Where(x => x is not null)) - { - var currentType = enumValue.GetValueKind() switch - { - JsonValueKind.Array => JsonSchemaType.Array, - JsonValueKind.String => JsonSchemaType.String, - JsonValueKind.Number => JsonSchemaType.Number, - JsonValueKind.True or JsonValueKind.False => JsonSchemaType.Boolean, - JsonValueKind.Null => (JsonSchemaType)0, - _ => JsonSchemaType.Object, - }; - - commonType |= currentType; - } - } - } - - return (commonType == 0 ? null : commonType, true); - } - else - { - return (null, true); - } - } - #if NET5_0_OR_GREATER private static readonly Array jsonSchemaTypeValues = System.Enum.GetValues(); #else diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs index 303997e7b..a83d19da8 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs @@ -17,8 +17,6 @@ namespace Microsoft.OpenApi.Reader.V3 /// internal static partial class OpenApiV3Deserializer { - private static readonly IOpenApiExtension _nullableTrueExtension = new JsonNodeExtension(JsonValue.Create(true)); - private static readonly FixedFieldMap _openApiSchemaFixedFields = new() { { @@ -231,15 +229,13 @@ internal static partial class OpenApiV3Deserializer } else { - // While we are dealing with v3 document, we are still using x-nullable extension here. - // This is used only as a marker during deserialization to indicate that the schema is nullable. - // When we complete the deserialization, we will modify the OpenApiSchema.Type to - // include JsonSchemaType.Null (**only if** needed), and always remove the extension. - // The reason is that "nullable" property should only take effect if "Type" is set. - // If we knew Type is set, we add "Null" to it. - // Otherwise, it might be that it will be set later. - // In this case, we add the marker extension, and check at the end of deserialization, and remove the extension. - o.AddExtension(OpenApiConstants.NullableExtension, _nullableTrueExtension); + // "nullable" is a type modifier. + // It only has effect if there is a "Type" set. + // Given that we don't have a Type set yet, we want to keep track of it until the end of deserialization. + // If, at a later point during deserialization, Type was set, we apply nullable to it. + // Otherwise, we throw it away. + o.Metadata ??= new Dictionary(); + o.Metadata["encounteredNullable"] = true; } } } @@ -400,15 +396,10 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum ParseMap(jsonObject, schema, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context); - if (schema.Extensions?.TryGetValue(OpenApiConstants.NullableExtension, out var value) == true && - value == _nullableTrueExtension) - { - // If this is "our own" internal _nullableTrueExtension instance, then we know we encountered "nullable": true in the schema - // at a point where Type wasn't set yet. - // We check now if schema.Type is set. - // If it is, we add JsonSchemaType.Null to it. - schema.Extensions.Remove(OpenApiConstants.NullableExtension); - if (schema.Type is not null && schema.Type != 0) + if (schema.Metadata?.TryGetValue("encounteredNullable", out var value) == true) + { + schema.Metadata.Remove("encounteredNullable"); + if (schema.Type is not null && schema.Type != 0 && value is bool isNullable && isNullable) { schema.Type |= JsonSchemaType.Null; } diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index de0842c62..b4b188617 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -959,7 +959,6 @@ public async Task SerializeOneOfWithNullAsV3ShouldUseNullableAsync() var expectedV3Schema = """ { - "type": "string", "oneOf": [ { "enum": [ @@ -970,8 +969,7 @@ public async Task SerializeOneOfWithNullAsV3ShouldUseNullableAsync() "maxLength": 10, "type": "string" } - ], - "nullable": true + ] } """; @@ -1057,7 +1055,6 @@ public async Task SerializeAnyOfWithNullAsV3ShouldUseNullableAsync() var expectedV3Schema = """ { - "type": "object", "anyOf": [ { "enum": [ @@ -1072,8 +1069,7 @@ public async Task SerializeAnyOfWithNullAsV3ShouldUseNullableAsync() } } } - ], - "nullable": true + ] } """; // Assert @@ -1254,7 +1250,6 @@ public async Task SerializeOneOfWithNullAndRefAsV3ShouldUseNullableAsync() var expectedV3Schema = """ { - "type": "object", "oneOf": [ { "enum": [ @@ -1264,8 +1259,7 @@ public async Task SerializeOneOfWithNullAndRefAsV3ShouldUseNullableAsync() { "$ref": "#/components/schemas/Pet" } - ], - "nullable": true + ] } """; @@ -1899,7 +1893,6 @@ public async Task SerializeNullableEnumWith3_0() var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); var expected = """ { - "type": "string", "oneOf": [ { "enum": [ @@ -1912,8 +1905,7 @@ public async Task SerializeNullableEnumWith3_0() "B" ] } - ], - "nullable": true + ] } """;