From 8b6048c520adfa028493f6f48d8d898393c31060 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 03:20:25 +0000 Subject: [PATCH 1/4] fix(schema): backport nullability round-trip handling Co-authored-by: Youssef1313 <31348972+Youssef1313@users.noreply.github.com> --- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 31 +++---- .../Reader/JsonNodeHelper.cs | 8 +- .../Reader/V3/OpenApiSchemaDeserializer.cs | 7 ++ .../OpenApiSchemaV30CompatibilityTests.cs | 89 +++++++++++++++++++ 4 files changed, 113 insertions(+), 22 deletions(-) create mode 100644 test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaV30CompatibilityTests.cs diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index 225548d1f..81917568d 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -521,20 +521,17 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version IList? effectiveOneOf = OneOf; IList? effectiveAnyOf = AnyOf; bool hasNullInComposition = false; - JsonSchemaType? inferredType = null; if (version == OpenApiSpecVersion.OpenApi3_0) { - (effectiveOneOf, var inferredOneOf, var nullInOneOf) = ProcessCompositionForNull(OneOf); + (effectiveOneOf, var nullInOneOf) = ProcessCompositionForNull(OneOf); hasNullInComposition |= nullInOneOf; - inferredType = inferredOneOf ?? inferredType; - (effectiveAnyOf, var inferredAnyOf, var nullInAnyOf) = ProcessCompositionForNull(AnyOf); + (effectiveAnyOf, var nullInAnyOf) = ProcessCompositionForNull(AnyOf); hasNullInComposition |= nullInAnyOf; - inferredType = inferredAnyOf ?? inferredType; } // type - SerializeTypeProperty(writer, version, inferredType); + SerializeTypeProperty(writer, version); // allOf writer.WriteOptionalCollection(OpenApiConstants.AllOf, AllOf, callback); @@ -971,10 +968,9 @@ private void SerializeAsV2( writer.WriteEndObject(); } - private void SerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion version, JsonSchemaType? inferredType = null) + private void SerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion version) { - // Use original type or inferred type when the explicit type is not set - var typeToUse = Type ?? inferredType; + var typeToUse = Type; if (typeToUse is null) { @@ -1050,14 +1046,14 @@ 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 (IList? effective, JsonSchemaType? inferredType, bool hasNullInComposition) + /// A tuple with the effective list and whether null is present in composition. + private static (IList? effective, 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 (composition, false); } var nonNullSchemas = composition @@ -1066,18 +1062,11 @@ private static (IList? effective, JsonSchemaType? inferredType, if (nonNullSchemas.Count > 0) { - JsonSchemaType commonType = 0; - - foreach (var schema in nonNullSchemas) - { - commonType |= schema.Type.GetValueOrDefault() & ~JsonSchemaType.Null; - } - - return (nonNullSchemas, commonType, true); + return (nonNullSchemas, true); } else { - return (null, null, true); + return (null, true); } } diff --git a/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs b/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs index 3ccfb2116..a67065374 100644 --- a/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs +++ b/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs @@ -42,7 +42,13 @@ public static List CreateListOfAny(this JsonNode? node, ParsingContext throw new OpenApiReaderException("Cannot create a list from this type of node.", context); } - return jsonArray.OfType().ToList(); + var list = new List(jsonArray.Count); + foreach (var element in jsonArray) + { + list.Add(element ?? JsonNullSentinel.JsonNull); + } + + return list; } public static List CreateSimpleList(this JsonNode? node, Func map, OpenApiDocument? openApiDocument, ParsingContext context) diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs index 6a933fe5f..4d2409993 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs @@ -395,6 +395,13 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum schema.Extensions.Remove(OpenApiConstants.NullableExtension); } + if (schema.Type is null && schema.Enum is { Count: 1 } && + schema.Enum[0].IsJsonNullSentinel()) + { + schema.Enum = null; + schema.Type = JsonSchemaType.Null; + } + return schema; } } diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaV30CompatibilityTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaV30CompatibilityTests.cs new file mode 100644 index 000000000..8e9bc1522 --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaV30CompatibilityTests.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.OpenApi.Tests.Models +{ + [Collection("DefaultSettings")] + public class OpenApiSchemaV30CompatibilityTests + { + private static IOpenApiSchema ParseSchemaFromV30Document(string schemaJson) + { + var jsonContent = $$""" + { + "openapi": "3.0.0", + "info": { "title": "Test", "version": "1.0" }, + "paths": {}, + "components": { + "schemas": { + "TestSchema": {{schemaJson}} + } + } + } + """; + + var readResult = OpenApiDocument.Parse(jsonContent, "json"); + Assert.Empty(readResult.Diagnostic.Errors); + return readResult.Document.Components.Schemas["TestSchema"]; + } + + [Fact] + public async Task NullableEnumShouldRoundTripCorrectly() + { + var schema = new OpenApiSchema + { + Enum = + [ + JsonValue.Create(1), + JsonValue.Create(2), + JsonNullSentinel.JsonNull, + ] + }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var deserializedSchema = ParseSchemaFromV30Document(actual); + + Assert.Equal(3, deserializedSchema.Enum.Count); + Assert.Equal(1, deserializedSchema.Enum[0].GetValue()); + Assert.Equal(2, deserializedSchema.Enum[1].GetValue()); + Assert.True(deserializedSchema.Enum[2].IsJsonNullSentinel()); + } + + [Fact] + public async Task TypeNullAloneAsV3ShouldRoundTripCorrectly() + { + var schema = new OpenApiSchema { Type = JsonSchemaType.Null }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var expected = + """ + { + "enum": [ + null + ], + "nullable": true + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + + var deserializedSchema = ParseSchemaFromV30Document(actual); + + Assert.Equal(JsonSchemaType.Null, deserializedSchema.Type); + Assert.Null(deserializedSchema.Enum); + } + + [Fact] + public async Task NullableTypeAsV3ShouldRoundTripCorrectly() + { + var schema = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var deserializedSchema = ParseSchemaFromV30Document(actual); + + Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, deserializedSchema.Type); + } + } +} From fd745326edfefc4369fad16f76b95898ffd44322 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 03:21:33 +0000 Subject: [PATCH 2/4] test(schema): cover nullability downgrade behavior Co-authored-by: Youssef1313 <31348972+Youssef1313@users.noreply.github.com> --- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 12 +++++++----- .../Models/OpenApiSchemaTests.cs | 3 --- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index 81917568d..f68649e23 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -510,11 +510,13 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version }); // enum - var enumValue = Enum is not { Count: > 0 } - && !string.IsNullOrEmpty(Const) - && version < OpenApiSpecVersion.OpenApi3_1 - ? new List { JsonValue.Create(Const)! } - : Enum; + var enumValue = Type is JsonSchemaType.Null && version == OpenApiSpecVersion.OpenApi3_0 + ? new List { JsonNullSentinel.JsonNull } + : Enum is not { Count: > 0 } + && !string.IsNullOrEmpty(Const) + && version < OpenApiSpecVersion.OpenApi3_1 + ? new List { JsonValue.Create(Const)! } + : Enum; writer.WriteOptionalCollection(OpenApiConstants.Enum, enumValue, (nodeWriter, s) => nodeWriter.WriteAny(s)); // Handle oneOf/anyOf with null type for v3.0 downcast diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index cf92e25a4..09933e501 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -956,7 +956,6 @@ public async Task SerializeOneOfWithNullAsV3ShouldUseNullableAsync() var expectedV3Schema = """ { - "type": "string", "oneOf": [ { "maxLength": 10, @@ -1045,7 +1044,6 @@ public async Task SerializeAnyOfWithNullAsV3ShouldUseNullableAsync() var expectedV3Schema = """ { - "type": "object", "anyOf": [ { "type": "object", @@ -1227,7 +1225,6 @@ public async Task SerializeOneOfWithNullAndRefAsV3ShouldUseNullableAsync() var expectedV3Schema = """ { - "type": "object", "oneOf": [ { "$ref": "#/components/schemas/Pet" From fbf2abab27056c987568ef62f11fbd15bbb29e2c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 03:22:17 +0000 Subject: [PATCH 3/4] fix(reader): normalize null-only enums Co-authored-by: Youssef1313 <31348972+Youssef1313@users.noreply.github.com> --- 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 4d2409993..06d048776 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs @@ -395,7 +395,7 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum schema.Extensions.Remove(OpenApiConstants.NullableExtension); } - if (schema.Type is null && schema.Enum is { Count: 1 } && + if ((schema.Type is null or JsonSchemaType.Null) && schema.Enum is { Count: 1 } && schema.Enum[0].IsJsonNullSentinel()) { schema.Enum = null; From b7bf4923246ad8337a564547efd83c8ee2278e25 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 03:23:32 +0000 Subject: [PATCH 4/4] test(reader): update nullable schema expectation Co-authored-by: Youssef1313 <31348972+Youssef1313@users.noreply.github.com> --- .../V3Tests/OpenApiSchemaTests.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs index 4e8b80286..f8711efd9 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs @@ -533,7 +533,12 @@ public async Task SerializeSchemaWithNullableShouldSucceed() public async Task SerializeSchemaWithOnlyNullableShouldSucceed() { // Arrange - var expected = @"nullable: true"; + var expected = + """ + enum: + - null + nullable: true + """; var path = Path.Combine(SampleFolderPath, "schemaWithOnlyNullable.yaml");