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]