diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index 225548d1f..4d41a6e0e 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,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); @@ -1070,10 +1094,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 cf92e25a4..b3048db00 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,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();