From f4778e024e6bf033a4b6c4b90ea6636de712f826 Mon Sep 17 00:00:00 2001 From: Youssef Fahmy Date: Fri, 3 Jul 2026 16:22:08 +0200 Subject: [PATCH 1/2] fix: handling of nullable enums for 3.0 (#2920) * Fix handling of nullable enums for 3.0 * Address comments * Add * Use JsonNullSentinel.JsonNull --- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 52 ++++++++++++- .../Models/OpenApiSchemaTests.cs | 75 +++++++++++++++++++ 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index cbf8c2f93..071eeb9ff 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -516,6 +516,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) @@ -526,6 +527,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 @@ -538,7 +542,27 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version writer.WriteOptionalCollection(OpenApiConstants.AnyOf, effectiveAnyOf, callback); // oneOf - writer.WriteOptionalCollection(OpenApiConstants.OneOf, effectiveOneOf, callback); + 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); + } + // not writer.WriteOptionalObject(OpenApiConstants.Not, Not, callback); @@ -1065,10 +1089,32 @@ 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 }) + { + 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; + } + + 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 8bc270e69..421893e57 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; @@ -1847,6 +1850,78 @@ 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); + var expected = """ + { + "type": "string", + "oneOf": [ + { + "enum": [ + "A", + "B", + null + ] + } + ], + "nullable": true + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + } + + [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); + var expected = """ + { + "oneOf": [ + { + "type": "null" + }, + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + } + + 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; + } + internal class SchemaVisitor : OpenApiVisitorBase { public List Titles = new(); From 76e42287f14b1b4d4738334729f4103d90487639 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 3 Jul 2026 10:24:17 -0400 Subject: [PATCH 2/2] tests: removes 3.2 version after cherry-pick Signed-off-by: Vincent Biret --- test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index 421893e57..b4e143b25 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -1883,7 +1883,6 @@ public async Task SerializeNullableEnumWith3_0() [Theory] [InlineData(OpenApiSpecVersion.OpenApi3_1)] - [InlineData(OpenApiSpecVersion.OpenApi3_2)] public async Task SerializeNullableEnumWith3_1_And_Later(OpenApiSpecVersion version) { var schema = CreateNullableEnumSchema();