From a663f6f616df2ee551204bccaaf5c70812f41bf5 Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Wed, 15 Jul 2026 11:27:27 +0200 Subject: [PATCH] Do not ignore multiple types when serializing to 3.0 --- .../Extensions/OpenApiTypeMapper.cs | 7 +- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 140 +++-- .../Models/OpenApiSchemaTests.cs | 491 ++++++++++++++++++ 3 files changed, 597 insertions(+), 41 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 2ae310294..5b80f8180 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; } @@ -549,7 +549,7 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version } // type - var serializedTypeProperty = TrySerializeTypeProperty(writer, version); + var serializedTypeProperty = TrySerializeTypePropertyForVersion3AndLater(writer, version, callback); // allOf writer.WriteOptionalCollection(OpenApiConstants.AllOf, AllOf, callback); @@ -681,7 +681,7 @@ internal void WriteJsonSchemaKeywords(IOpenApiWriter writer, Action s.SerializeAsV2(w)); } - #pragma warning restore CS0618 +#pragma warning restore CS0618 } // properties @@ -996,32 +996,113 @@ private void SerializeAsV2( writer.WriteEndObject(); } - private bool TrySerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion version, JsonSchemaType? inferredType = null) + private bool TrySerializeTypePropertyForVersion2(IOpenApiWriter writer) { - // Use original type or inferred type when the explicit type is not set - var typeToUse = Type ?? inferredType; + if (Type is not { } type) + { + return false; + } - if (typeToUse is null) + var typeWithoutNull = type & ~JsonSchemaType.Null; + if (typeWithoutNull != 0 && !HasMultipleTypes(typeWithoutNull)) + { + writer.WriteProperty(OpenApiConstants.Type, typeWithoutNull.ToFirstIdentifier()); + return true; + } + + return false; + } + + /// + /// Tries to serialize the "type" property for OpenAPI v3 and later versions. + /// + /// + /// true if the Type was serializable using "type" property, and false if + /// it serialized using anyOf/oneOf or if it couldn't be serialized at all. + /// + private bool TrySerializeTypePropertyForVersion3AndLater(IOpenApiWriter writer, OpenApiSpecVersion version, Action callback) + { + if (Type is not { } type) { return false; } - 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 false; + } + + 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.WriteOptionalCollection(OpenApiConstants.AnyOf, ConstructChildSchemasForTypes(arrayWithoutNull, hasNull), callback); + return false; + } + else if (canWriteAsOneOf) { - writer.WriteProperty(OpenApiConstants.Type, typeWithoutNull.ToFirstIdentifier()); - return true; + writer.WriteOptionalCollection(OpenApiConstants.OneOf, ConstructChildSchemasForTypes(arrayWithoutNull, hasNull), callback); + return false; } - break; - default: - WriteUnifiedSchemaType(typeToUse.Value, writer); + } + else + { + writer.WriteProperty(OpenApiConstants.Type, arrayWithoutNull[0].ToSingleIdentifier()); return true; + } + } + 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 + { + writer.WriteProperty(OpenApiConstants.Type, array[0].ToSingleIdentifier()); + } + + return true; } return false; + + 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 static bool IsPowerOfTwo(int x) @@ -1035,27 +1116,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/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index b4b188617..f2c669f7c 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -1968,6 +1968,497 @@ 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))); + } + + [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))); + } + + [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))); + } + + [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))); + } + + [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))); + } + + [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))); + } + + [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))); + } + + [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))); + } + + [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 + ] + } + ] + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + } + + [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 + ] + } + ], + "oneOf": [ + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + } + + [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 + ] + } + ], + "anyOf": [ + { + "enum": [ + "A", + "B" + ] + } + ] + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + } + + [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" + ] + } + ] + } + """; + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(result))); + } + private OpenApiSchema CreateNullableEnumSchema() { var schema = new OpenApiSchema();