From 6ee0df13a9040482ad8a0ccd18dd196fdc95830b Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Wed, 1 Jul 2026 18:26:58 +0200 Subject: [PATCH 1/2] Fix handling of nullable enums for 3.0 --- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 31 +++++++- .../Models/OpenApiSchemaTests.cs | 77 +++++++++++++++++++ 2 files changed, 105 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index 225548d1f..49f5ede7f 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -521,6 +521,7 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version IList? effectiveOneOf = OneOf; IList? effectiveAnyOf = AnyOf; bool hasNullInComposition = false; + bool hasOneOfNullAndSingleEnumWith3_0 = false; JsonSchemaType? inferredType = null; if (version == OpenApiSpecVersion.OpenApi3_0) @@ -531,6 +532,9 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version (effectiveAnyOf, var inferredAnyOf, var nullInAnyOf) = ProcessCompositionForNull(AnyOf); hasNullInComposition |= nullInAnyOf; inferredType = inferredAnyOf ?? inferredType; + + hasOneOfNullAndSingleEnumWith3_0 = nullInOneOf && effectiveOneOf is { Count: 1 } && + effectiveOneOf[0].Enum is { Count: > 0 }; } // type @@ -543,7 +547,21 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version writer.WriteOptionalCollection(OpenApiConstants.AnyOf, effectiveAnyOf, callback); // oneOf - writer.WriteOptionalCollection(OpenApiConstants.OneOf, effectiveOneOf, callback); + if (hasOneOfNullAndSingleEnumWith3_0 && + effectiveOneOf![0] is OpenApiSchema { Enum.Count: > 0 } singleEffectiveOneOf) + { + writer.WriteRequiredCollection(OpenApiConstants.OneOf, effectiveOneOf, (writer, element) => + { + var clonedToMutateEnum = (OpenApiSchema)((OpenApiSchema)element).MemberwiseClone(); + clonedToMutateEnum.Enum = [.. clonedToMutateEnum.Enum!, null!]; + callback(writer, clonedToMutateEnum); + }); + } + else + { + writer.WriteOptionalCollection(OpenApiConstants.OneOf, effectiveOneOf, callback); + } + // not writer.WriteOptionalObject(OpenApiConstants.Not, Not, callback); @@ -1070,10 +1088,17 @@ private static (IList? effective, JsonSchemaType? inferredType, foreach (var schema in nonNullSchemas) { - commonType |= schema.Type.GetValueOrDefault() & ~JsonSchemaType.Null; + if (schema.Type.HasValue) + { + commonType |= schema.Type.Value & ~JsonSchemaType.Null; + } + else if (schema.Enum is { Count: > 0 }) + { + commonType |= JsonSchemaType.String; + } } - return (nonNullSchemas, commonType, true); + return (nonNullSchemas, commonType == 0 ? null : commonType, true); } else { diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index cf92e25a4..2ebfe57fd 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -5,7 +5,10 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Schema; +using System.Text.Json.Serialization; using System.Threading.Tasks; using FluentAssertions; using VerifyXunit; @@ -1853,6 +1856,80 @@ public void DeserializeContainsExtensionsInV3AssignsContainsProperties() Assert.True(schema.Extensions is null || !schema.Extensions.ContainsKey(OpenApiConstants.MinContainsExtension)); } + [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); + Assert.Equal(""" + { + "type": "string", + "oneOf": [ + { + "enum": [ + "A", + "B", + null + ] + } + ], + "nullable": true + } + """.ReplaceLineEndings(), result.ReplaceLineEndings()); + } + + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi3_1)] + [InlineData(OpenApiSpecVersion.OpenApi3_2)] + public async Task SerializeNullableEnumWith3_1_And_Later(OpenApiSpecVersion version) + { + var schema = CreateNullableEnumSchema(); + var result = await schema.SerializeAsJsonAsync(version); + Assert.Equal(""" + { + "oneOf": [ + { + "type": "null" + }, + { + "enum": [ + "A", + "B" + ] + } + ] + } + """.ReplaceLineEndings(), result.ReplaceLineEndings()); + } + + private OpenApiSchema CreateNullableEnumSchema() + { + var schema = new OpenApiSchema(); + schema.OneOf ??= []; + schema.OneOf.Add(new OpenApiSchema() { Type = JsonSchemaType.Null }); + schema.OneOf.Add(new OpenApiSchema() + { + Enum = new List + { + JsonValue.Create("A"), + JsonValue.Create("B") + } + }); + return schema; + } + + private enum MyEnum + { + A, B + } + internal class SchemaVisitor : OpenApiVisitorBase { public List Titles = new(); From 69119bb258118cd0212ef9c6cee4c9163fd3d463 Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Thu, 2 Jul 2026 13:59:46 +0200 Subject: [PATCH 2/2] Address comments --- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 37 +++++++++++++++---- .../Models/OpenApiSchemaTests.cs | 16 ++++---- 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index 49f5ede7f..0ca179f05 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -547,14 +547,20 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version writer.WriteOptionalCollection(OpenApiConstants.AnyOf, effectiveAnyOf, callback); // oneOf - if (hasOneOfNullAndSingleEnumWith3_0 && - effectiveOneOf![0] is OpenApiSchema { Enum.Count: > 0 } singleEffectiveOneOf) + if (hasOneOfNullAndSingleEnumWith3_0) { - writer.WriteRequiredCollection(OpenApiConstants.OneOf, effectiveOneOf, (writer, element) => - { - var clonedToMutateEnum = (OpenApiSchema)((OpenApiSchema)element).MemberwiseClone(); - clonedToMutateEnum.Enum = [.. clonedToMutateEnum.Enum!, null!]; - callback(writer, clonedToMutateEnum); + writer.WriteRequiredCollection(OpenApiConstants.OneOf, effectiveOneOf!, (writer, element) => + { + var clonedToMutateEnum = element.CreateShallowCopy(); + if (clonedToMutateEnum is OpenApiSchema { Enum: { } existingEnum } concreteCloned) + { + concreteCloned.Enum = [.. existingEnum, null!]; + callback(writer, clonedToMutateEnum); + } + else + { + callback(writer, element); + } }); } else @@ -1094,6 +1100,23 @@ private static (IList? effective, JsonSchemaType? inferredType, } else if (schema.Enum is { Count: > 0 }) { + foreach (var enumValue in schema.Enum) + { + if (enumValue 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; + } + } commonType |= JsonSchemaType.String; } } diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index 2ebfe57fd..b3048db00 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -1868,7 +1868,7 @@ public async Task SerializeNullableEnumWith3_0() // 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); - Assert.Equal(""" + var expected = """ { "type": "string", "oneOf": [ @@ -1882,7 +1882,9 @@ public async Task SerializeNullableEnumWith3_0() ], "nullable": true } - """.ReplaceLineEndings(), result.ReplaceLineEndings()); + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); } [Theory] @@ -1892,7 +1894,7 @@ public async Task SerializeNullableEnumWith3_1_And_Later(OpenApiSpecVersion vers { var schema = CreateNullableEnumSchema(); var result = await schema.SerializeAsJsonAsync(version); - Assert.Equal(""" + var expected = """ { "oneOf": [ { @@ -1906,7 +1908,8 @@ public async Task SerializeNullableEnumWith3_1_And_Later(OpenApiSpecVersion vers } ] } - """.ReplaceLineEndings(), result.ReplaceLineEndings()); + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); } private OpenApiSchema CreateNullableEnumSchema() @@ -1925,11 +1928,6 @@ private OpenApiSchema CreateNullableEnumSchema() return schema; } - private enum MyEnum - { - A, B - } - internal class SchemaVisitor : OpenApiVisitorBase { public List Titles = new();