From 38720fe783c10de567fc7faae60e3ad5800f4dd4 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Thu, 23 Jul 2026 13:55:15 -0400 Subject: [PATCH 1/4] feat: adds converters for the null enum/type scenario Signed-off-by: Vincent Biret --- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 15 - src/Microsoft.OpenApi/PublicAPI.Unshipped.txt | 10 + .../OpenAPIV3_1ToV3SerializationConverter.cs | 54 +++ ...OpenApiV3ToV3_1DeserializationConverter.cs | 51 +++ .../Models/OpenApiSchemaTests.cs | 397 ++++++++++++++++-- 5 files changed, 479 insertions(+), 48 deletions(-) create mode 100644 src/Microsoft.OpenApi/Services/OpenAPIV3_1ToV3SerializationConverter.cs create mode 100644 src/Microsoft.OpenApi/Services/OpenApiV3ToV3_1DeserializationConverter.cs diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index c24f66a5e..cdc7323e6 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -21,8 +21,6 @@ namespace Microsoft.OpenApi /// public class OpenApiSchema : IOpenApiExtensible, IOpenApiSchema, IOpenApiSchemaMissingProperties, IOpenApiSchemaWithUnevaluatedProperties, IMetadataContainer, IDeepCopyable { - private static readonly IEnumerable s_singleNullElementList = [ JsonNullSentinel.JsonNull ]; - /// public string? Title { get; set; } @@ -536,19 +534,6 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version : Enum; writer.WriteOptionalCollection(OpenApiConstants.Enum, enumValue, (nodeWriter, s) => nodeWriter.WriteAny(s)); - if (version == OpenApiSpecVersion.OpenApi3_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 } && - AllOf 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); diff --git a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt index 0967394cb..be1370b5c 100644 --- a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt +++ b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt @@ -3,6 +3,16 @@ const Microsoft.OpenApi.OpenApiConstants.JsonSchemaExamplesExtension = "x-jsonsc const Microsoft.OpenApi.OpenApiConstants.OaiLicenseIdentifier = "x-oai-license-identifier" -> string! [OPENAPI001]Microsoft.OpenApi.IDeepCopyable [OPENAPI001]Microsoft.OpenApi.IDeepCopyable.CreateDeepCopy() -> T +[OPENAPI001]Microsoft.OpenApi.OpenApiV3_1ToV3SerializationConverter +[OPENAPI001]Microsoft.OpenApi.OpenApiV3_1ToV3SerializationConverter.Convert(Microsoft.OpenApi.IOpenApiSchema! schema) -> void +[OPENAPI001]Microsoft.OpenApi.OpenApiV3_1ToV3SerializationConverter.Convert(Microsoft.OpenApi.OpenApiDocument! document) -> void +[OPENAPI001]Microsoft.OpenApi.OpenApiV3_1ToV3SerializationConverter.OpenApiV3_1ToV3SerializationConverter() -> void +[OPENAPI001]override Microsoft.OpenApi.OpenApiV3_1ToV3SerializationConverter.Visit(Microsoft.OpenApi.IOpenApiSchema! schema) -> void +[OPENAPI001]Microsoft.OpenApi.OpenApiV3ToV3_1DeserializationConverter +[OPENAPI001]Microsoft.OpenApi.OpenApiV3ToV3_1DeserializationConverter.Convert(Microsoft.OpenApi.IOpenApiSchema! schema) -> void +[OPENAPI001]Microsoft.OpenApi.OpenApiV3ToV3_1DeserializationConverter.Convert(Microsoft.OpenApi.OpenApiDocument! document) -> void +[OPENAPI001]Microsoft.OpenApi.OpenApiV3ToV3_1DeserializationConverter.OpenApiV3ToV3_1DeserializationConverter() -> void +[OPENAPI001]override Microsoft.OpenApi.OpenApiV3ToV3_1DeserializationConverter.Visit(Microsoft.OpenApi.IOpenApiSchema! schema) -> void [OPENAPI001]Microsoft.OpenApi.OpenApiCallback.CreateDeepCopy() -> Microsoft.OpenApi.IOpenApiCallback! [OPENAPI001]Microsoft.OpenApi.OpenApiCallbackReference.CreateDeepCopy() -> Microsoft.OpenApi.IOpenApiCallback! [OPENAPI001]Microsoft.OpenApi.OpenApiComponents.CreateDeepCopy() -> Microsoft.OpenApi.OpenApiComponents! diff --git a/src/Microsoft.OpenApi/Services/OpenAPIV3_1ToV3SerializationConverter.cs b/src/Microsoft.OpenApi/Services/OpenAPIV3_1ToV3SerializationConverter.cs new file mode 100644 index 000000000..7cebba746 --- /dev/null +++ b/src/Microsoft.OpenApi/Services/OpenAPIV3_1ToV3SerializationConverter.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Nodes; + +namespace Microsoft.OpenApi; + +/// +/// Converts OpenAPI 3.1 schema constructs into OpenAPI 3.0 serialization-compatible constructs. +/// +[Experimental("OPENAPI001")] +public sealed class OpenApiV3_1ToV3SerializationConverter : OpenApiVisitorBase +{ + /// + /// Applies the conversion to an OpenAPI document. + /// + /// The document to convert. + public void Convert(OpenApiDocument document) + { + Utils.CheckArgumentNull(document); + new OpenApiWalker(this).Walk(document); + } + + /// + /// Applies the conversion to a schema. + /// + /// The schema to convert. + public void Convert(IOpenApiSchema schema) + { + Utils.CheckArgumentNull(schema); + new OpenApiWalker(this).Walk(schema); + } + + /// + public override void Visit(IOpenApiSchema schema) + { + if (schema is OpenApiSchema concreteSchema && ShouldConvertNullTypeToNullEnum(concreteSchema)) + { + concreteSchema.Enum = new List + { + JsonNullSentinel.JsonNull + }; + } + } + + private static bool ShouldConvertNullTypeToNullEnum(OpenApiSchema schema) => + schema.Type == JsonSchemaType.Null && + schema.OneOf is not { Count: > 0 } && + schema.AnyOf is not { Count: > 0 } && + schema.AllOf is not { Count: > 0 } && + schema.Enum is not { Count: > 0 }; +} diff --git a/src/Microsoft.OpenApi/Services/OpenApiV3ToV3_1DeserializationConverter.cs b/src/Microsoft.OpenApi/Services/OpenApiV3ToV3_1DeserializationConverter.cs new file mode 100644 index 000000000..e0866d73f --- /dev/null +++ b/src/Microsoft.OpenApi/Services/OpenApiV3ToV3_1DeserializationConverter.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.OpenApi; + +/// +/// Converts OpenAPI 3.0 deserialized schema constructs into OpenAPI 3.1 constructs. +/// +[Experimental("OPENAPI001")] +public sealed class OpenApiV3ToV3_1DeserializationConverter : OpenApiVisitorBase +{ + /// + /// Applies the conversion to an OpenAPI document. + /// + /// The document to convert. + public void Convert(OpenApiDocument document) + { + Utils.CheckArgumentNull(document); + new OpenApiWalker(this).Walk(document); + } + + /// + /// Applies the conversion to a schema. + /// + /// The schema to convert. + public void Convert(IOpenApiSchema schema) + { + Utils.CheckArgumentNull(schema); + new OpenApiWalker(this).Walk(schema); + } + + /// + public override void Visit(IOpenApiSchema schema) + { + if (schema is OpenApiSchema concreteSchema && ShouldConvertNullEnumToNullType(concreteSchema)) + { + concreteSchema.Type = JsonSchemaType.Null; + concreteSchema.Enum = null; + } + } + + private static bool ShouldConvertNullEnumToNullType(OpenApiSchema schema) => + schema.Type is null && + schema.OneOf is not { Count: > 0 } && + schema.AnyOf is not { Count: > 0 } && + schema.AllOf is not { Count: > 0 } && + schema.Enum is { Count: 1 } enumValues && + enumValues[0].IsJsonNullSentinel(); +} diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index db4de6355..29ed9762c 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. +#pragma warning disable OPENAPI001 + using System; using System.Collections.Generic; using System.Globalization; @@ -962,11 +964,7 @@ public async Task SerializeOneOfWithNullAsV3ShouldUseNullableAsync() """ { "oneOf": [ - { - "enum": [ - null - ] - }, + { }, { "maxLength": 10, "type": "string" @@ -1006,11 +1004,7 @@ public async Task SerializeOneOfWithNullAndMultipleSchemasAsV3ShouldMarkItAsNull """ { "oneOf": [ - { - "enum": [ - null - ] - }, + { }, { "type": "string" }, @@ -1058,11 +1052,7 @@ public async Task SerializeAnyOfWithNullAsV3ShouldUseNullableAsync() """ { "anyOf": [ - { - "enum": [ - null - ] - }, + { }, { "type": "object", "properties": { @@ -1105,11 +1095,7 @@ public async Task SerializeAnyOfWithNullAndMultipleSchemasAsV3ShouldApplyNullabl """ { "anyOf": [ - { - "enum": [ - null - ] - }, + { }, { "minLength": 1, "type": "string" @@ -1150,11 +1136,7 @@ public async Task SerializeOneOfWithOnlyNullAsV3ShouldJustBeNullableAsync() """ { "oneOf": [ - { - "enum": [ - null - ] - } + { } ] } """; @@ -1249,6 +1231,242 @@ public async Task SerializeOneOfWithNullAndRefAsV3ShouldUseNullableAsync() var v3Schema = outputStringWriter.GetStringBuilder().ToString(); + var expectedV3Schema = + """ + { + "oneOf": [ + { }, + { + "$ref": "#/components/schemas/Pet" + } + ] + } + """; + + // Assert + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV3Schema), JsonNode.Parse(v3Schema))); + } + + [Fact] + public async Task SerializeOneOfWithNullAsConvertedV3ShouldUseNullEnumAsync() + { + var schema = new OpenApiSchema + { + OneOf = new List + { + new OpenApiSchema { Type = JsonSchemaType.Null }, + new OpenApiSchema + { + Type = JsonSchemaType.String, + MaxLength = 10 + } + } + }; + + var v3Schema = await SerializeConvertedCloneAsV3Async(schema); + + var expectedV3Schema = + """ + { + "oneOf": [ + { + "enum": [ + null + ] + }, + { + "maxLength": 10, + "type": "string" + } + ] + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV3Schema), JsonNode.Parse(v3Schema))); + } + + [Fact] + public async Task SerializeOneOfWithNullAndMultipleSchemasAsConvertedV3ShouldUseNullEnum() + { + var schema = new OpenApiSchema + { + OneOf = new List + { + new OpenApiSchema { Type = JsonSchemaType.Null }, + new OpenApiSchema { Type = JsonSchemaType.String }, + new OpenApiSchema { Type = JsonSchemaType.Number }, + } + }; + + var v3Schema = await SerializeConvertedCloneAsV3Async(schema); + + var expectedV3Schema = + """ + { + "oneOf": [ + { + "enum": [ + null + ] + }, + { + "type": "string" + }, + { + "type": "number" + } + ] + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV3Schema), JsonNode.Parse(v3Schema))); + } + + [Fact] + public async Task SerializeAnyOfWithNullAsConvertedV3ShouldUseNullEnumAsync() + { + var schema = new OpenApiSchema + { + AnyOf = new List + { + new OpenApiSchema { Type = JsonSchemaType.Null }, + new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["id"] = new OpenApiSchema { Type = JsonSchemaType.Integer } + } + } + } + }; + + var v3Schema = await SerializeConvertedCloneAsV3Async(schema); + + var expectedV3Schema = + """ + { + "anyOf": [ + { + "enum": [ + null + ] + }, + { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + } + } + ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV3Schema), JsonNode.Parse(v3Schema))); + } + + [Fact] + public async Task SerializeAnyOfWithNullAndMultipleSchemasAsConvertedV3ShouldUseNullEnum() + { + var schema = new OpenApiSchema + { + AnyOf = new List + { + new OpenApiSchema { Type = JsonSchemaType.Null }, + new OpenApiSchema { Type = JsonSchemaType.String, MinLength = 1 }, + new OpenApiSchema { Type = JsonSchemaType.Integer } + } + }; + + var v3Schema = await SerializeConvertedCloneAsV3Async(schema); + + var expectedV3Schema = + """ + { + "anyOf": [ + { + "enum": [ + null + ] + }, + { + "minLength": 1, + "type": "string" + }, + { + "type": "integer" + } + ] + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV3Schema), JsonNode.Parse(v3Schema))); + } + + [Fact] + public async Task SerializeOneOfWithOnlyNullAsConvertedV3ShouldUseNullEnumAsync() + { + var schema = new OpenApiSchema + { + OneOf = new List + { + new OpenApiSchema { Type = JsonSchemaType.Null } + } + }; + + var v3Schema = await SerializeConvertedCloneAsV3Async(schema); + + var expectedV3Schema = + """ + { + "oneOf": [ + { + "enum": [ + null + ] + } + ] + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV3Schema), JsonNode.Parse(v3Schema))); + } + + [Fact] + public async Task SerializeOneOfWithNullAndRefAsConvertedV3ShouldUseNullEnumAsync() + { + var document = new OpenApiDocument + { + Components = new OpenApiComponents + { + Schemas = new Dictionary + { + ["Pet"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["id"] = new OpenApiSchema { Type = JsonSchemaType.Integer }, + ["name"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + } + } + } + }; + document.Workspace.RegisterComponents(document); + var schemaRef = new OpenApiSchemaReference("Pet", document); + var schema = new OpenApiSchema + { + OneOf = new List + { + new OpenApiSchema { Type = JsonSchemaType.Null }, + schemaRef + } + }; + + var v3Schema = await SerializeConvertedCloneAsV3Async(schema); + var expectedV3Schema = """ { @@ -1265,10 +1483,86 @@ public async Task SerializeOneOfWithNullAndRefAsV3ShouldUseNullableAsync() } """; - // Assert Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV3Schema), JsonNode.Parse(v3Schema))); } + [Fact] + public async Task ConvertSingleNullEnumSchemaToV31TypeNullAsync() + { + var schema = new OpenApiSchema + { + Enum = new List + { + JsonNullSentinel.JsonNull + } + }; + + new OpenApiV3ToV3_1DeserializationConverter().Convert(schema); + + Assert.Equal(JsonSchemaType.Null, schema.Type); + Assert.Null(schema.Enum); + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var expected = """ + { + "type": "null" + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + } + + [Fact] + public void ConvertSingleNullEnumNestedInDocumentToV31TypeNull() + { + var nullSchema = new OpenApiSchema + { + Enum = new List + { + JsonNullSentinel.JsonNull + } + }; + var document = new OpenApiDocument + { + Components = new OpenApiComponents + { + Schemas = new Dictionary + { + ["NullableName"] = new OpenApiSchema + { + OneOf = new List + { + nullSchema, + new OpenApiSchema { Type = JsonSchemaType.String } + } + } + } + } + }; + + new OpenApiV3ToV3_1DeserializationConverter().Convert(document); + + Assert.Equal(JsonSchemaType.Null, nullSchema.Type); + Assert.Null(nullSchema.Enum); + } + + [Fact] + public void ConvertDoesNotChangeEnumWithNullAndOtherValues() + { + var schema = new OpenApiSchema + { + Enum = new List + { + JsonNullSentinel.JsonNull, + JsonValue.Create("A") + } + }; + + new OpenApiV3ToV3_1DeserializationConverter().Convert(schema); + + Assert.Null(schema.Type); + Assert.NotNull(schema.Enum); + Assert.Equal(2, schema.Enum.Count); + } + [Fact] public async Task SerializeContainsKeywordsAsV31Works() { @@ -2035,15 +2329,30 @@ public void DeserializeContainsExtensionsInV3AssignsContainsProperties() [Fact] public async Task SerializeNullableEnumWith3_0() { - // https://spec.openapis.org/oas/v3.0.4.html#fixed-fields-20 - // Documentation for nullable states: - // This keyword only takes effect if type is explicitly defined within the same Schema Object. - // So, we want to ensure that we emit the type property if we will be adding nullable property. - // In addition, we need to still keep 'null' in the enum array. - // Otherwise, validators will consider null as invalid even if nullable is set to true. - // It's unclear if it's an issue of the validators or not, but it's safer to do it that way. var schema = CreateNullableEnumSchema(); var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var expected = """ + { + "oneOf": [ + { }, + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + } + + [Fact] + public async Task SerializeNullableEnumWithConverted3_0() + { + var schema = CreateNullableEnumSchema(); + var result = await SerializeConvertedCloneAsV3Async(schema); var expected = """ { "oneOf": [ @@ -2095,6 +2404,16 @@ public async Task SerializeNullableTypeWith3_0() { var schema = CreateTypeNullSchema(); var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var expected = "{ }"; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + } + + [Fact] + public async Task SerializeNullableTypeWithConverted3_0() + { + var schema = CreateTypeNullSchema(); + var result = await SerializeConvertedCloneAsV3Async(schema); var expected = """ { "enum": [ @@ -2121,6 +2440,18 @@ public async Task SerializeNullableTypeWith3_1_And_Later(OpenApiSpecVersion vers Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); } + private static async Task SerializeConvertedCloneAsV3Async(OpenApiSchema schema) + { + var convertedSchema = Assert.IsType(schema.CreateDeepCopy()); + new OpenApiV3_1ToV3SerializationConverter().Convert(convertedSchema); + + var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(outputStringWriter, new() { Terse = false }); + convertedSchema.SerializeAsV3(writer); + await writer.FlushAsync(); + return outputStringWriter.GetStringBuilder().ToString(); + } + private OpenApiSchema CreateNullableEnumSchema() { var schema = new OpenApiSchema(); From a27a07e6eeedd8ceaa7be36fc8ec3fa59bc26da2 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Thu, 23 Jul 2026 14:01:29 -0400 Subject: [PATCH 2/4] chore: fixes file name casing Signed-off-by: Vincent Biret --- ...ionConverter.cs => OpenApiV3_1ToV3SerializationConverter.cs} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/Microsoft.OpenApi/Services/{OpenAPIV3_1ToV3SerializationConverter.cs => OpenApiV3_1ToV3SerializationConverter.cs} (96%) diff --git a/src/Microsoft.OpenApi/Services/OpenAPIV3_1ToV3SerializationConverter.cs b/src/Microsoft.OpenApi/Services/OpenApiV3_1ToV3SerializationConverter.cs similarity index 96% rename from src/Microsoft.OpenApi/Services/OpenAPIV3_1ToV3SerializationConverter.cs rename to src/Microsoft.OpenApi/Services/OpenApiV3_1ToV3SerializationConverter.cs index 7cebba746..1e2fb0047 100644 --- a/src/Microsoft.OpenApi/Services/OpenAPIV3_1ToV3SerializationConverter.cs +++ b/src/Microsoft.OpenApi/Services/OpenApiV3_1ToV3SerializationConverter.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; From b83bdd0c676afb715ae57f871349eb64ba8a9f25 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Thu, 23 Jul 2026 14:02:01 -0400 Subject: [PATCH 3/4] feat: adds API at document level to perform conversions Signed-off-by: Vincent Biret --- .../Models/OpenApiDocument.cs | 20 ++++++- src/Microsoft.OpenApi/PublicAPI.Unshipped.txt | 1 + .../Models/OpenApiSchemaTests.cs | 54 +++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiDocument.cs b/src/Microsoft.OpenApi/Models/OpenApiDocument.cs index 456a8244f..17290dd1f 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiDocument.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiDocument.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; @@ -161,6 +161,24 @@ public OpenApiDocument CreateDeepCopy() return new OpenApiDeepCopyContext().Copy(this); } + /// + /// Applies semantic conversions needed to represent this document in a different OpenAPI specification version. + /// + /// The OpenAPI specification version this document currently represents. + /// The target OpenAPI specification version. + [System.Diagnostics.CodeAnalysis.Experimental("OPENAPI001")] + public void ApplySemanticConversions(OpenApiSpecVersion currentApiSpecVersion, OpenApiSpecVersion targetApiSpecVersion) + { + if (currentApiSpecVersion <= OpenApiSpecVersion.OpenApi3_0 && targetApiSpecVersion >= OpenApiSpecVersion.OpenApi3_1) + { + new OpenApiV3ToV3_1DeserializationConverter().Convert(this); + } + else if (currentApiSpecVersion >= OpenApiSpecVersion.OpenApi3_1 && targetApiSpecVersion <= OpenApiSpecVersion.OpenApi3_0) + { + new OpenApiV3_1ToV3SerializationConverter().Convert(this); + } + } + /// /// Serialize to an Open API document using the specified version. /// diff --git a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt index be1370b5c..f4389a454 100644 --- a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt +++ b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt @@ -19,6 +19,7 @@ const Microsoft.OpenApi.OpenApiConstants.OaiLicenseIdentifier = "x-oai-license-i [OPENAPI001]Microsoft.OpenApi.OpenApiContact.CreateDeepCopy() -> Microsoft.OpenApi.OpenApiContact! [OPENAPI001]Microsoft.OpenApi.OpenApiDiscriminator.CreateDeepCopy() -> Microsoft.OpenApi.OpenApiDiscriminator! [OPENAPI001]Microsoft.OpenApi.OpenApiDocument.CreateDeepCopy() -> Microsoft.OpenApi.OpenApiDocument! +[OPENAPI001]Microsoft.OpenApi.OpenApiDocument.ApplySemanticConversions(Microsoft.OpenApi.OpenApiSpecVersion currentApiSpecVersion, Microsoft.OpenApi.OpenApiSpecVersion targetApiSpecVersion) -> void [OPENAPI001]Microsoft.OpenApi.OpenApiEncoding.CreateDeepCopy() -> Microsoft.OpenApi.OpenApiEncoding! [OPENAPI001]Microsoft.OpenApi.OpenApiExample.CreateDeepCopy() -> Microsoft.OpenApi.IOpenApiExample! [OPENAPI001]Microsoft.OpenApi.OpenApiExampleReference.CreateDeepCopy() -> Microsoft.OpenApi.IOpenApiExample! diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index 29ed9762c..9e8bb2485 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -1563,6 +1563,60 @@ public void ConvertDoesNotChangeEnumWithNullAndOtherValues() Assert.Equal(2, schema.Enum.Count); } + [Fact] + public void ApplySemanticConversionsFromV3ToV31ConvertsNullEnumToNullType() + { + var nullSchema = new OpenApiSchema + { + Enum = new List + { + JsonNullSentinel.JsonNull + } + }; + var document = new OpenApiDocument + { + Components = new OpenApiComponents + { + Schemas = new Dictionary + { + ["NullableValue"] = nullSchema + } + } + }; + + document.ApplySemanticConversions(OpenApiSpecVersion.OpenApi3_0, OpenApiSpecVersion.OpenApi3_1); + + Assert.Equal(JsonSchemaType.Null, nullSchema.Type); + Assert.Null(nullSchema.Enum); + } + + [Fact] + public void ApplySemanticConversionsFromV31ToV3ConvertsNullTypeToNullEnum() + { + var nullSchema = new OpenApiSchema + { + Type = JsonSchemaType.Null + }; + var document = new OpenApiDocument + { + Components = new OpenApiComponents + { + Schemas = new Dictionary + { + ["NullableValue"] = nullSchema + } + } + }; + + document.ApplySemanticConversions(OpenApiSpecVersion.OpenApi3_1, OpenApiSpecVersion.OpenApi3_0); + + Assert.Equal(JsonSchemaType.Null, nullSchema.Type); + var enumValues = nullSchema.Enum; + Assert.NotNull(enumValues); + Assert.Single(enumValues); + Assert.True(enumValues[0].IsJsonNullSentinel()); + } + [Fact] public async Task SerializeContainsKeywordsAsV31Works() { From 269b050033d2e8217bacfb7cbe13782396cbd1a0 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Thu, 23 Jul 2026 15:14:10 -0400 Subject: [PATCH 4/4] fix: restores nullable emission for v3 Signed-off-by: Vincent Biret --- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 9 +--- .../OpenApiV3_1ToV3SerializationConverter.cs | 1 + .../Models/OpenApiSchemaTests.cs | 54 +++++++++++++++---- 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index cdc7323e6..4977a9fa5 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -535,7 +535,7 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version writer.WriteOptionalCollection(OpenApiConstants.Enum, enumValue, (nodeWriter, s) => nodeWriter.WriteAny(s)); // type - var serializedTypeProperty = TrySerializeTypeProperty(writer, version); + TrySerializeTypeProperty(writer, version); // allOf writer.WriteOptionalCollection(OpenApiConstants.AllOf, AllOf, callback); @@ -580,13 +580,8 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version writer.WriteOptionalObject(OpenApiConstants.Default, Default, (w, d) => w.WriteAny(d)); // nullable - if (version == OpenApiSpecVersion.OpenApi3_0 && serializedTypeProperty) + if (version == OpenApiSpecVersion.OpenApi3_0) { - // 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); } diff --git a/src/Microsoft.OpenApi/Services/OpenApiV3_1ToV3SerializationConverter.cs b/src/Microsoft.OpenApi/Services/OpenApiV3_1ToV3SerializationConverter.cs index 1e2fb0047..91bbacc19 100644 --- a/src/Microsoft.OpenApi/Services/OpenApiV3_1ToV3SerializationConverter.cs +++ b/src/Microsoft.OpenApi/Services/OpenApiV3_1ToV3SerializationConverter.cs @@ -42,6 +42,7 @@ public override void Visit(IOpenApiSchema schema) { JsonNullSentinel.JsonNull }; + concreteSchema.Type = null; } } diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index 9e8bb2485..9ac9458d4 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -964,7 +964,9 @@ public async Task SerializeOneOfWithNullAsV3ShouldUseNullableAsync() """ { "oneOf": [ - { }, + { + "nullable": true + }, { "maxLength": 10, "type": "string" @@ -1004,7 +1006,9 @@ public async Task SerializeOneOfWithNullAndMultipleSchemasAsV3ShouldMarkItAsNull """ { "oneOf": [ - { }, + { + "nullable": true + }, { "type": "string" }, @@ -1052,7 +1056,9 @@ public async Task SerializeAnyOfWithNullAsV3ShouldUseNullableAsync() """ { "anyOf": [ - { }, + { + "nullable": true + }, { "type": "object", "properties": { @@ -1095,7 +1101,9 @@ public async Task SerializeAnyOfWithNullAndMultipleSchemasAsV3ShouldApplyNullabl """ { "anyOf": [ - { }, + { + "nullable": true + }, { "minLength": 1, "type": "string" @@ -1136,7 +1144,9 @@ public async Task SerializeOneOfWithOnlyNullAsV3ShouldJustBeNullableAsync() """ { "oneOf": [ - { } + { + "nullable": true + } ] } """; @@ -1235,7 +1245,9 @@ public async Task SerializeOneOfWithNullAndRefAsV3ShouldUseNullableAsync() """ { "oneOf": [ - { }, + { + "nullable": true + }, { "$ref": "#/components/schemas/Pet" } @@ -1610,7 +1622,7 @@ public void ApplySemanticConversionsFromV31ToV3ConvertsNullTypeToNullEnum() document.ApplySemanticConversions(OpenApiSpecVersion.OpenApi3_1, OpenApiSpecVersion.OpenApi3_0); - Assert.Equal(JsonSchemaType.Null, nullSchema.Type); + Assert.Null(nullSchema.Type); var enumValues = nullSchema.Enum; Assert.NotNull(enumValues); Assert.Single(enumValues); @@ -2388,7 +2400,9 @@ public async Task SerializeNullableEnumWith3_0() var expected = """ { "oneOf": [ - { }, + { + "nullable": true + }, { "enum": [ "A", @@ -2458,7 +2472,29 @@ public async Task SerializeNullableTypeWith3_0() { var schema = CreateTypeNullSchema(); var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); - var expected = "{ }"; + var expected = """ + { + "nullable": true + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + } + + [Fact] + public async Task SerializeMultipleTypesIncludingNullWith3_0EmitsNullable() + { + var schema = new OpenApiSchema + { + Type = JsonSchemaType.String | JsonSchemaType.Integer | JsonSchemaType.Null + }; + + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var expected = """ + { + "nullable": true + } + """; Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); }