From 6568896ccf14380e234bae481a83be2d58d7c0cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:44:41 +0000 Subject: [PATCH] fix(library): serialize multiple schema types as anyOf/oneOf for OpenAPI 3.0 Backport of microsoft/OpenAPI.NET#2960 to support/v2. Co-authored-by: Youssef1313 <31348972+Youssef1313@users.noreply.github.com> --- .../Extensions/OpenApiTypeMapper.cs | 7 +- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 130 ++- .../Reader/V3/OpenApiSchemaDeserializer.cs | 106 +++ .../Models/OpenApiSchemaTests.cs | 749 ++++++++++++++++++ .../OpenApiSchemaV30CompatibilityTests.cs | 69 +- 5 files changed, 1015 insertions(+), 46 deletions(-) diff --git a/src/Microsoft.OpenApi/Extensions/OpenApiTypeMapper.cs b/src/Microsoft.OpenApi/Extensions/OpenApiTypeMapper.cs index 8d557b4d1..1ad3a5b8f 100644 --- a/src/Microsoft.OpenApi/Extensions/OpenApiTypeMapper.cs +++ b/src/Microsoft.OpenApi/Extensions/OpenApiTypeMapper.cs @@ -69,7 +69,12 @@ internal static string ToFirstIdentifier(this JsonSchemaType schemaType) /// internal static string ToSingleIdentifier(this JsonSchemaType schemaType) { - return schemaType.ToIdentifiersInternal().Single(); + if (allSchemaTypes.TryGetValue(schemaType, out var schemaTypeString)) + { + return schemaTypeString; + } + + throw new InvalidOperationException($"ToSingleIdentifier is called with unexpected value '{schemaType}'. Callers must ensure this is called with a valid single value JsonSchemaType."); } /// diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index 97925922e..970d036a0 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -21,7 +21,7 @@ namespace Microsoft.OpenApi /// public class OpenApiSchema : IOpenApiExtensible, IOpenApiSchema, IOpenApiSchemaMissingProperties, IOpenApiSchemaWithUnevaluatedProperties, IMetadataContainer { - private static readonly IEnumerable s_singleNullElementList = [ JsonNullSentinel.JsonNull ]; + private static readonly IEnumerable s_singleNullElementList = [JsonNullSentinel.JsonNull]; /// public string? Title { get; set; } @@ -545,7 +545,7 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version } // type - SerializeTypeProperty(writer, version); + SerializeTypePropertyForVersion3AndLater(writer, version, callback); // allOf writer.WriteOptionalCollection(OpenApiConstants.AllOf, AllOf, callback); @@ -680,7 +680,7 @@ internal void WriteJsonSchemaKeywords(IOpenApiWriter writer, Action s.SerializeAsV2(w)); } - #pragma warning restore CS0618 +#pragma warning restore CS0618 } // properties @@ -1001,31 +1001,104 @@ private void SerializeAsV2( writer.WriteEndObject(); } - private void SerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion version) + private void SerializeTypePropertyForVersion2(IOpenApiWriter writer) { - var typeToUse = Type; + if (Type is not { } type || type == JsonSchemaType.Null) + { + return; + } + + var typeWithoutNull = type & ~JsonSchemaType.Null; + if (!HasMultipleTypes(typeWithoutNull)) + { + writer.WriteProperty(OpenApiConstants.Type, typeWithoutNull.ToFirstIdentifier()); + } + } - if (typeToUse is null) + /// + /// Serializes the "type" property for OpenAPI v3 and later versions, + /// falling back to anyOf/oneOf when multiple types cannot be expressed + /// using the "type" property alone (OpenAPI 3.0). + /// + private void SerializeTypePropertyForVersion3AndLater(IOpenApiWriter writer, OpenApiSpecVersion version, Action callback) + { + if (Type is not { } type) { return; } - switch (version) + if (version == OpenApiSpecVersion.OpenApi3_0) { - case OpenApiSpecVersion.OpenApi2_0 or OpenApiSpecVersion.OpenApi3_0: - var typeWithoutNull = typeToUse.Value & ~JsonSchemaType.Null; - if (typeWithoutNull != 0 && !HasMultipleTypes(typeWithoutNull)) + if (type == JsonSchemaType.Null) + { + return; + } + + var typeWithoutNull = type & ~JsonSchemaType.Null; + var hasNull = typeWithoutNull != type; + var arrayWithoutNull = (from JsonSchemaType flag in jsonSchemaTypeValues + where typeWithoutNull.HasFlag(flag) + select flag).ToArray(); + + // - If we have more than one type (excluding null), we have to use anyOf/oneOf. + // - If we have exactly one type alone (without null), we emit the type property. + // - If we have exactly one non-null type and also we have the null type, we emit the type property and nullable: true (handled in SerializeNullable) + if (arrayWithoutNull.Length > 1) + { + // If the schema doesn't already have anyOf/oneOf, we can write multiple types as such. + var canWriteAsAnyOf = AnyOf is not { Count: > 0 }; + var canWriteAsOneOf = OneOf is not { Count: > 0 }; + if (canWriteAsAnyOf) { - writer.WriteProperty(OpenApiConstants.Type, typeWithoutNull.ToFirstIdentifier()); + writer.WriteOptionalCollection(OpenApiConstants.AnyOf, ConstructChildSchemasForTypes(arrayWithoutNull, hasNull), callback); return; } - break; - default: - WriteUnifiedSchemaType(typeToUse.Value, writer); + else if (canWriteAsOneOf) + { + writer.WriteOptionalCollection(OpenApiConstants.OneOf, ConstructChildSchemasForTypes(arrayWithoutNull, hasNull), callback); + return; + } + } + else if (arrayWithoutNull.Length == 1) + { + writer.WriteProperty(OpenApiConstants.Type, arrayWithoutNull[0].ToSingleIdentifier()); return; + } + } + else + { + var array = (from JsonSchemaType flag in jsonSchemaTypeValues + where type.HasFlag(flag) + select flag).ToArray(); + + if (array.Length > 1) + { + writer.WriteOptionalCollection(OpenApiConstants.Type, array, (w, s) => w.WriteValue(s.ToSingleIdentifier())); + } + else if (array.Length == 1) + { + writer.WriteProperty(OpenApiConstants.Type, array[0].ToSingleIdentifier()); + } } - return; + static OpenApiSchema[] ConstructChildSchemasForTypes(JsonSchemaType[] types, bool hasNull) + { + var schemas = new OpenApiSchema[types.Length + (hasNull ? 1 : 0)]; + for (int i = 0; i < types.Length; i++) + { + schemas[i] = new OpenApiSchema() + { + Type = types[i] + }; + } + + if (hasNull) + { + schemas[schemas.Length - 1] = new OpenApiSchema() { Type = JsonSchemaType.Null }; + } + + return schemas; + } } private JsonNode? GetCompatibilityExample() @@ -1063,27 +1136,6 @@ private static bool HasMultipleTypes(JsonSchemaType schemaType) return !IsPowerOfTwo(schemaTypeNumeric); } - private static void WriteUnifiedSchemaType(JsonSchemaType type, IOpenApiWriter writer) - { - var array = (from JsonSchemaType flag in jsonSchemaTypeValues - where type.HasFlag(flag) - select flag.ToFirstIdentifier()).ToArray(); - if (array.Length > 1) - { - writer.WriteOptionalCollection(OpenApiConstants.Type, array, (w, s) => - { - if (!string.IsNullOrEmpty(s) && s is not null) - { - w.WriteValue(s); - } - }); - } - else - { - writer.WriteProperty(OpenApiConstants.Type, array[0]); - } - } - private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version) { if (HasNullType) diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs index bf0b7bca4..7bb9327b6 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs @@ -420,7 +420,113 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum schema.Type = JsonSchemaType.Null; } + if (schema.Type is null) + { + if (schema.AnyOf is not null && + schema.AnyOf.All(s => s is OpenApiSchema child && DoesSchemaRepresentSingleType(child))) + { + JsonSchemaType types = GetAllTypes(schema.AnyOf); + schema.AnyOf = null; + schema.Type = types; + } + else if (schema.OneOf is not null && + schema.OneOf.All(s => s is OpenApiSchema child && DoesSchemaRepresentSingleType(child))) + { + JsonSchemaType types = GetAllTypes(schema.OneOf); + schema.OneOf = null; + schema.Type = types; + } + } + return schema; } + + private static JsonSchemaType GetAllTypes(IList schemas) + { + JsonSchemaType types = 0; + foreach (var schema in schemas) + { + types |= schema.Type!.Value; + } + + return types; + } + + private static bool DoesSchemaRepresentSingleType(OpenApiSchema schema) + { + if (schema.Type is not (JsonSchemaType.Null or + JsonSchemaType.Boolean or + JsonSchemaType.Integer or + JsonSchemaType.Number or + JsonSchemaType.String or + JsonSchemaType.Object or + JsonSchemaType.Array)) + { + return false; + } + + // Folding anyOf/oneOf back into a single "type" is only safe when the child + // schema carries nothing but its type. Otherwise any additional keywords + // (format, bounds, enum, nested schemas, etc.) would be silently dropped. + // Metadata is intentionally ignored as it only holds internal bookkeeping. + return schema.Title is null && + schema.Schema is null && + schema.Id is null && + schema.Comment is null && + schema.Vocabulary is null && + schema.DynamicRef is null && + schema.DynamicAnchor is null && + schema.Definitions is null && + schema.Anchor is null && + schema.Format is null && + schema.Description is null && + schema.Maximum is null && + schema.Minimum is null && + schema.ExclusiveMaximum is null && + schema.ExclusiveMinimum is null && + schema.MaxLength is null && + schema.MinLength is null && + schema.Pattern is null && + schema.MultipleOf is null && + schema.Default is null && + !schema.ReadOnly && + !schema.WriteOnly && + schema.AllOf is null && + schema.OneOf is null && + schema.AnyOf is null && + schema.Not is null && + schema.Required is null && + schema.Items is null && + schema.MaxItems is null && + schema.MinItems is null && + schema.UniqueItems is null && + schema.Contains is null && + schema.MaxContains is null && + schema.MinContains is null && + schema.Properties is null && + schema.PatternProperties is null && + schema.MaxProperties is null && + schema.MinProperties is null && + schema.AdditionalPropertiesAllowed && + schema.AdditionalProperties is null && + schema.Discriminator is null && + schema.Enum is null && + schema.UnevaluatedProperties && + schema.UnevaluatedPropertiesSchema is null && + schema.ContentEncoding is null && + schema.ContentMediaType is null && + schema.ContentSchema is null && + schema.PropertyNames is null && + schema.DependentSchemas is null && + schema.DependentRequired is null && + schema.If is null && + schema.Then is null && + schema.Else is null && + schema.ExternalDocs is null && + !schema.Deprecated && + schema.Xml is null && + schema.Extensions is null && + schema.UnrecognizedKeywords is null; + } } } diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index 35af7edc0..d7135585a 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -11,6 +11,7 @@ using System.Text.Json.Serialization; using System.Threading.Tasks; using FluentAssertions; +using Microsoft.OpenApi.Reader; using VerifyXunit; using Xunit; @@ -2120,6 +2121,754 @@ public async Task SerializeNullableTypeWith3_1_And_Later(OpenApiSpecVersion vers Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); } + [Fact] + public async Task SerializeMultipleTypesWith3_0_NotIncludingNull_NotIncludingOneOfOrAnyOf() + { + var schema = new OpenApiSchema() + { + Type = JsonSchemaType.String | JsonSchemaType.Integer, + }; + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var expected = """ + { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + + var deserializedSchema = OpenApiModelFactory.Parse(expected, OpenApiSpecVersion.OpenApi3_0, new OpenApiDocument(), out _); + Assert.Null(deserializedSchema.OneOf); + Assert.Null(deserializedSchema.AnyOf); + Assert.Equal(schema.Type, deserializedSchema.Type); + + var actual31 = await deserializedSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var expected31 = """ + { + "type": [ "integer", "string" ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected31), JsonNode.Parse(actual31))); + } + + [Fact] + public async Task SerializeMultipleTypesWith3_0_NotIncludingNull_IncludingOneOfButNotAnyOf() + { + var schema = new OpenApiSchema() + { + Type = JsonSchemaType.String | JsonSchemaType.Integer, + OneOf = + [ + new OpenApiSchema() + { + Enum = new List + { + JsonValue.Create("A"), + JsonValue.Create("B") + } + }, + ], + }; + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var expected = """ + { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ], + "oneOf": [ + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + + var deserializedSchema = OpenApiModelFactory.Parse(expected, OpenApiSpecVersion.OpenApi3_0, new OpenApiDocument(), out _); + Assert.NotNull(deserializedSchema.OneOf); + Assert.Null(deserializedSchema.AnyOf); + Assert.Equal(schema.Type, deserializedSchema.Type); + + var actual31 = await deserializedSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var expected31 = """ + { + "type": [ "integer", "string" ], + "oneOf": [ + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected31), JsonNode.Parse(actual31))); + } + + [Fact] + public async Task SerializeMultipleTypesWith3_0_NotIncludingNull_IncludingAnyOfButNotOneOf() + { + var schema = new OpenApiSchema() + { + Type = JsonSchemaType.String | JsonSchemaType.Integer, + AnyOf = + [ + new OpenApiSchema() + { + Enum = new List + { + JsonValue.Create("A"), + JsonValue.Create("B") + } + }, + ], + }; + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var expected = """ + { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ], + "anyOf": [ + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + + var deserializedSchema = OpenApiModelFactory.Parse(expected, OpenApiSpecVersion.OpenApi3_0, new OpenApiDocument(), out _); + Assert.Null(deserializedSchema.OneOf); + Assert.NotNull(deserializedSchema.AnyOf); + Assert.Equal(schema.Type, deserializedSchema.Type); + + var actual31 = await deserializedSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var expected31 = """ + { + "type": [ "integer", "string" ], + "anyOf": [ + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected31), JsonNode.Parse(actual31))); + } + + [Fact] + public async Task SerializeMultipleTypesWith3_0_NotIncludingNull_IncludingBothOneOfAndAnyOf() + { + var schema = new OpenApiSchema() + { + Type = JsonSchemaType.String | JsonSchemaType.Integer, + AnyOf = + [ + new OpenApiSchema() + { + Enum = new List + { + JsonValue.Create("A"), + JsonValue.Create("B") + } + }, + ], + OneOf = + [ + new OpenApiSchema() + { + Enum = new List + { + JsonValue.Create("A"), + JsonValue.Create("B") + } + }, + ], + }; + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var expected = """ + { + "anyOf": [ + { + "enum": [ + "A", + "B" + ] + } + ], + "oneOf": [ + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + + var deserializedSchema = OpenApiModelFactory.Parse(expected, OpenApiSpecVersion.OpenApi3_0, new OpenApiDocument(), out _); + Assert.NotNull(deserializedSchema.OneOf); + Assert.NotNull(deserializedSchema.AnyOf); + Assert.Null(deserializedSchema.Type); + + var actual31 = await deserializedSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var expected31 = """ + { + "anyOf": [ + { + "enum": [ + "A", + "B" + ] + } + ], + "oneOf": [ + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected31), JsonNode.Parse(actual31))); + } + + [Fact] + public async Task SerializeMultipleTypesWith3_0_IncludingNullAndSingleType_NotIncludingOneOfOrAnyOf() + { + var schema = new OpenApiSchema() + { + Type = JsonSchemaType.String | JsonSchemaType.Null, + }; + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var expected = """ + { + "type": "string", + "nullable": true + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + + var deserializedSchema = OpenApiModelFactory.Parse(expected, OpenApiSpecVersion.OpenApi3_0, new OpenApiDocument(), out _); + Assert.Null(deserializedSchema.OneOf); + Assert.Null(deserializedSchema.AnyOf); + Assert.Equal(schema.Type, deserializedSchema.Type); + + var actual31 = await deserializedSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var expected31 = """ + { + "type": [ "null", "string" ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected31), JsonNode.Parse(actual31))); + } + + [Fact] + public async Task SerializeMultipleTypesWith3_0_IncludingNullAndSingleType_IncludingOneOfButNotAnyOf() + { + var schema = new OpenApiSchema() + { + Type = JsonSchemaType.String | JsonSchemaType.Null, + OneOf = + [ + new OpenApiSchema() + { + Enum = new List + { + JsonValue.Create("A"), + JsonValue.Create("B") + } + }, + ], + }; + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var expected = """ + { + "type": "string", + "oneOf": [ + { + "enum": [ + "A", + "B" + ] + } + ], + "nullable": true + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + + var deserializedSchema = OpenApiModelFactory.Parse(expected, OpenApiSpecVersion.OpenApi3_0, new OpenApiDocument(), out _); + Assert.NotNull(deserializedSchema.OneOf); + Assert.Null(deserializedSchema.AnyOf); + Assert.Equal(schema.Type, deserializedSchema.Type); + + var actual31 = await deserializedSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var expected31 = """ + { + "type": [ "null", "string" ], + "oneOf": [ + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected31), JsonNode.Parse(actual31))); + } + + [Fact] + public async Task SerializeMultipleTypesWith3_0_IncludingNullAndSingleType_IncludingAnyOfButNotOneOf() + { + var schema = new OpenApiSchema() + { + Type = JsonSchemaType.String | JsonSchemaType.Null, + AnyOf = + [ + new OpenApiSchema() + { + Enum = new List + { + JsonValue.Create("A"), + JsonValue.Create("B") + } + }, + ], + }; + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var expected = """ + { + "type": "string", + "anyOf": [ + { + "enum": [ + "A", + "B" + ] + } + ], + "nullable": true + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + + var deserializedSchema = OpenApiModelFactory.Parse(expected, OpenApiSpecVersion.OpenApi3_0, new OpenApiDocument(), out _); + Assert.Null(deserializedSchema.OneOf); + Assert.NotNull(deserializedSchema.AnyOf); + Assert.Equal(schema.Type, deserializedSchema.Type); + + var actual31 = await deserializedSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var expected31 = """ + { + "type": [ "null", "string" ], + "anyOf": [ + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected31), JsonNode.Parse(actual31))); + } + + [Fact] + public async Task SerializeMultipleTypesWith3_0_IncludingNullAndSingleType_IncludingBothOneOfAndAnyOf() + { + var schema = new OpenApiSchema() + { + Type = JsonSchemaType.String | JsonSchemaType.Null, + AnyOf = + [ + new OpenApiSchema() + { + Enum = new List + { + JsonValue.Create("A"), + JsonValue.Create("B") + } + }, + ], + OneOf = + [ + new OpenApiSchema() + { + Enum = new List + { + JsonValue.Create("A"), + JsonValue.Create("B") + } + }, + ], + }; + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var expected = """ + { + "type": "string", + "anyOf": [ + { + "enum": [ + "A", + "B" + ] + } + ], + "oneOf": [ + { + "enum": [ + "A", + "B" + ] + } + ], + "nullable": true + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + + var deserializedSchema = OpenApiModelFactory.Parse(expected, OpenApiSpecVersion.OpenApi3_0, new OpenApiDocument(), out _); + Assert.NotNull(deserializedSchema.OneOf); + Assert.NotNull(deserializedSchema.AnyOf); + Assert.Equal(schema.Type, deserializedSchema.Type); + + var actual31 = await deserializedSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var expected31 = """ + { + "type": [ "null", "string" ], + "anyOf": [ + { + "enum": [ + "A", + "B" + ] + } + ], + "oneOf": [ + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected31), JsonNode.Parse(actual31))); + } + + [Fact] + public async Task SerializeMultipleTypesWith3_0_IncludingNullAndTwoTypes_NotIncludingOneOfOrAnyOf() + { + var schema = new OpenApiSchema() + { + Type = JsonSchemaType.String | JsonSchemaType.Integer | JsonSchemaType.Null, + }; + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var expected = """ + { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "enum": [ + null + ], + "nullable": true + } + ], + "nullable": true + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + + var deserializedSchema = OpenApiModelFactory.Parse(expected, OpenApiSpecVersion.OpenApi3_0, new OpenApiDocument(), out _); + Assert.Null(deserializedSchema.OneOf); + Assert.Null(deserializedSchema.AnyOf); + Assert.Equal(schema.Type, deserializedSchema.Type); + + var actual31 = await deserializedSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var expected31 = """ + { + "type": [ "null", "integer", "string" ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected31), JsonNode.Parse(actual31))); + } + + [Fact] + public async Task SerializeMultipleTypesWith3_0_IncludingNullAndTwoTypes_IncludingOneOfButNotAnyOf() + { + var schema = new OpenApiSchema() + { + Type = JsonSchemaType.String | JsonSchemaType.Integer | JsonSchemaType.Null, + OneOf = + [ + new OpenApiSchema() + { + Enum = new List + { + JsonValue.Create("A"), + JsonValue.Create("B") + } + }, + ], + }; + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var expected = """ + { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "enum": [ + null + ], + "nullable": true + } + ], + "oneOf": [ + { + "enum": [ + "A", + "B" + ] + } + ], + "nullable": true + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + + var deserializedSchema = OpenApiModelFactory.Parse(expected, OpenApiSpecVersion.OpenApi3_0, new OpenApiDocument(), out _); + Assert.NotNull(deserializedSchema.OneOf); + Assert.Null(deserializedSchema.AnyOf); + Assert.Equal(schema.Type, deserializedSchema.Type); + + var actual31 = await deserializedSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var expected31 = """ + { + "type": [ "null", "integer", "string" ], + "oneOf": [ + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected31), JsonNode.Parse(actual31))); + } + + [Fact] + public async Task SerializeMultipleTypesWith3_0_IncludingNullAndTwoTypes_IncludingAnyOfButNotOneOf() + { + var schema = new OpenApiSchema() + { + Type = JsonSchemaType.String | JsonSchemaType.Integer | JsonSchemaType.Null, + AnyOf = + [ + new OpenApiSchema() + { + Enum = new List + { + JsonValue.Create("A"), + JsonValue.Create("B") + } + }, + ], + }; + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var expected = """ + { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "enum": [ + null + ], + "nullable": true + } + ], + "anyOf": [ + { + "enum": [ + "A", + "B" + ] + } + ], + "nullable": true + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + + var deserializedSchema = OpenApiModelFactory.Parse(expected, OpenApiSpecVersion.OpenApi3_0, new OpenApiDocument(), out _); + Assert.Null(deserializedSchema.OneOf); + Assert.NotNull(deserializedSchema.AnyOf); + Assert.Equal(schema.Type, deserializedSchema.Type); + + var actual31 = await deserializedSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var expected31 = """ + { + "type": [ "null", "integer", "string" ], + "anyOf": [ + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected31), JsonNode.Parse(actual31))); + } + + [Fact] + public async Task SerializeMultipleTypesWith3_0_IncludingNullAndTwoTypes_IncludingBothOneOfAndAnyOf() + { + var schema = new OpenApiSchema() + { + Type = JsonSchemaType.String | JsonSchemaType.Integer | JsonSchemaType.Null, + AnyOf = + [ + new OpenApiSchema() + { + Enum = new List + { + JsonValue.Create("A"), + JsonValue.Create("B") + } + }, + ], + OneOf = + [ + new OpenApiSchema() + { + Enum = new List + { + JsonValue.Create("A"), + JsonValue.Create("B") + } + }, + ], + }; + var result = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var expected = """ + { + "anyOf": [ + { + "enum": [ + "A", + "B" + ] + } + ], + "oneOf": [ + { + "enum": [ + "A", + "B" + ] + } + ], + "nullable": true + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + + var deserializedSchema = OpenApiModelFactory.Parse(expected, OpenApiSpecVersion.OpenApi3_0, new OpenApiDocument(), out _); + Assert.NotNull(deserializedSchema.OneOf); + Assert.NotNull(deserializedSchema.AnyOf); + Assert.Null(deserializedSchema.Type); + + var actual31 = await deserializedSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var expected31 = """ + { + "anyOf": [ + { + "enum": [ + "A", + "B" + ] + } + ], + "oneOf": [ + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected31), JsonNode.Parse(actual31))); + } + private OpenApiSchema CreateNullableEnumSchema() { var schema = new OpenApiSchema(); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaV30CompatibilityTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaV30CompatibilityTests.cs index 4dc0e025b..1c272f837 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaV30CompatibilityTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaV30CompatibilityTests.cs @@ -118,28 +118,34 @@ public async Task NullableTypeAsV3ShouldRoundTripCorrectly() } [Fact] - public async Task SerializeMultipleNonNullTypesAsV3OmitsType() + public async Task SerializeMultipleNonNullTypesAsV3DoesNotOmitType() { - // Current behavior isn't good. It loses the information about multiple types. var schema = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Integer }; var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); var expected = """ { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] } """; Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); var deserializedSchema = ParseSchemaFromV30Document(actual); - Assert.Null(deserializedSchema.Type); + Assert.Equal(schema.Type, deserializedSchema.Type); } [Fact] - public async Task SerializeMultipleNonNullTypesWithNullAsV3OmitsTypeButKeepsNullable() + public async Task SerializeMultipleNonNullTypesWithNullAsV3DoesNotOmitType() { - // Current behavior isn't good. It loses the information about multiple types. var schema = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Integer | JsonSchemaType.Null @@ -149,14 +155,65 @@ public async Task SerializeMultipleNonNullTypesWithNullAsV3OmitsTypeButKeepsNull var expected = """ { - "nullable": true + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "enum": [ + null + ], + "nullable": true + } + ], + "nullable": true } """; Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); var deserializedSchema = ParseSchemaFromV30Document(actual); + Assert.Equal(schema.Type, deserializedSchema.Type); + } + + [Fact] + public void DeserializeAnyOfWithConstraintsAsV3PreservesConstraints() + { + var schemaJson = """ + { + "anyOf": [ + { + "type": "string", + "format": "email", + "maxLength": 10 + }, + { + "type": "integer", + "minimum": 0 + } + ] + } + """; + + var deserializedSchema = ParseSchemaFromV30Document(schemaJson); + Assert.Null(deserializedSchema.Type); + Assert.NotNull(deserializedSchema.AnyOf); + Assert.Collection(deserializedSchema.AnyOf, + first => + { + Assert.Equal(JsonSchemaType.String, first.Type); + Assert.Equal("email", first.Format); + Assert.Equal(10, first.MaxLength); + }, + second => + { + Assert.Equal(JsonSchemaType.Integer, second.Type); + Assert.Equal("0", second.Minimum); + }); } [Fact]