Skip to content

Commit 6568896

Browse files
CopilotYoussef1313
andauthored
fix(library): serialize multiple schema types as anyOf/oneOf for OpenAPI 3.0
Backport of #2960 to support/v2. Co-authored-by: Youssef1313 <31348972+Youssef1313@users.noreply.github.com>
1 parent 3f66f87 commit 6568896

5 files changed

Lines changed: 1015 additions & 46 deletions

File tree

src/Microsoft.OpenApi/Extensions/OpenApiTypeMapper.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,12 @@ internal static string ToFirstIdentifier(this JsonSchemaType schemaType)
6969
/// <returns></returns>
7070
internal static string ToSingleIdentifier(this JsonSchemaType schemaType)
7171
{
72-
return schemaType.ToIdentifiersInternal().Single();
72+
if (allSchemaTypes.TryGetValue(schemaType, out var schemaTypeString))
73+
{
74+
return schemaTypeString;
75+
}
76+
77+
throw new InvalidOperationException($"ToSingleIdentifier is called with unexpected value '{schemaType}'. Callers must ensure this is called with a valid single value JsonSchemaType.");
7378
}
7479

7580
/// <summary>

src/Microsoft.OpenApi/Models/OpenApiSchema.cs

Lines changed: 91 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ namespace Microsoft.OpenApi
2121
/// </summary>
2222
public class OpenApiSchema : IOpenApiExtensible, IOpenApiSchema, IOpenApiSchemaMissingProperties, IOpenApiSchemaWithUnevaluatedProperties, IMetadataContainer
2323
{
24-
private static readonly IEnumerable<JsonNode> s_singleNullElementList = [ JsonNullSentinel.JsonNull ];
24+
private static readonly IEnumerable<JsonNode> s_singleNullElementList = [JsonNullSentinel.JsonNull];
2525

2626
/// <inheritdoc />
2727
public string? Title { get; set; }
@@ -545,7 +545,7 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version
545545
}
546546

547547
// type
548-
SerializeTypeProperty(writer, version);
548+
SerializeTypePropertyForVersion3AndLater(writer, version, callback);
549549

550550
// allOf
551551
writer.WriteOptionalCollection(OpenApiConstants.AllOf, AllOf, callback);
@@ -680,7 +680,7 @@ internal void WriteJsonSchemaKeywords(IOpenApiWriter writer, Action<IOpenApiWrit
680680
writer.WriteProperty(OpenApiConstants.Id, Id);
681681
writer.WriteProperty(OpenApiConstants.DollarSchema, Schema?.ToString());
682682
writer.WriteProperty(OpenApiConstants.Comment, Comment);
683-
683+
684684
if (WasConstExplicitlySet)
685685
{
686686
writer.WriteRequiredProperty(OpenApiConstants.Const, Const);
@@ -691,7 +691,7 @@ internal void WriteJsonSchemaKeywords(IOpenApiWriter writer, Action<IOpenApiWrit
691691
writer.WriteProperty(OpenApiConstants.Anchor, Anchor);
692692
writer.WriteProperty(OpenApiConstants.DynamicRef, DynamicRef);
693693
writer.WriteProperty(OpenApiConstants.DynamicAnchor, DynamicAnchor);
694-
694+
695695
// UnevaluatedProperties: similar to AdditionalProperties, serialize as schema if present, else as boolean.
696696
// Only emit when the type could include objects.
697697
// Skip when type is explicitly set to a non-object type (array, string, number, integer, boolean, null).
@@ -833,7 +833,7 @@ private void SerializeAsV2(
833833
writer.WriteStartObject();
834834

835835
// type
836-
SerializeTypeProperty(writer, OpenApiSpecVersion.OpenApi2_0);
836+
SerializeTypePropertyForVersion2(writer);
837837

838838
// description
839839
writer.WriteProperty(OpenApiConstants.Description, Description);
@@ -915,7 +915,7 @@ private void SerializeAsV2(
915915
// oneOf (Not Supported in V2) - Write the first schema only as an allOf.
916916
writer.WriteOptionalCollection(OpenApiConstants.AllOf, OneOf?.Take(1), (w, s) => s.SerializeAsV2(w));
917917
}
918-
#pragma warning restore CS0618
918+
#pragma warning restore CS0618
919919
}
920920

921921
// properties
@@ -1001,31 +1001,104 @@ private void SerializeAsV2(
10011001
writer.WriteEndObject();
10021002
}
10031003

1004-
private void SerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion version)
1004+
private void SerializeTypePropertyForVersion2(IOpenApiWriter writer)
10051005
{
1006-
var typeToUse = Type;
1006+
if (Type is not { } type || type == JsonSchemaType.Null)
1007+
{
1008+
return;
1009+
}
1010+
1011+
var typeWithoutNull = type & ~JsonSchemaType.Null;
1012+
if (!HasMultipleTypes(typeWithoutNull))
1013+
{
1014+
writer.WriteProperty(OpenApiConstants.Type, typeWithoutNull.ToFirstIdentifier());
1015+
}
1016+
}
10071017

1008-
if (typeToUse is null)
1018+
/// <summary>
1019+
/// Serializes the "type" property for OpenAPI v3 and later versions,
1020+
/// falling back to anyOf/oneOf when multiple types cannot be expressed
1021+
/// using the "type" property alone (OpenAPI 3.0).
1022+
/// </summary>
1023+
private void SerializeTypePropertyForVersion3AndLater(IOpenApiWriter writer, OpenApiSpecVersion version, Action<IOpenApiWriter, IOpenApiSerializable> callback)
1024+
{
1025+
if (Type is not { } type)
10091026
{
10101027
return;
10111028
}
10121029

1013-
switch (version)
1030+
if (version == OpenApiSpecVersion.OpenApi3_0)
10141031
{
1015-
case OpenApiSpecVersion.OpenApi2_0 or OpenApiSpecVersion.OpenApi3_0:
1016-
var typeWithoutNull = typeToUse.Value & ~JsonSchemaType.Null;
1017-
if (typeWithoutNull != 0 && !HasMultipleTypes(typeWithoutNull))
1032+
if (type == JsonSchemaType.Null)
1033+
{
1034+
return;
1035+
}
1036+
1037+
var typeWithoutNull = type & ~JsonSchemaType.Null;
1038+
var hasNull = typeWithoutNull != type;
1039+
var arrayWithoutNull = (from JsonSchemaType flag in jsonSchemaTypeValues
1040+
where typeWithoutNull.HasFlag(flag)
1041+
select flag).ToArray();
1042+
1043+
// - If we have more than one type (excluding null), we have to use anyOf/oneOf.
1044+
// - If we have exactly one type alone (without null), we emit the type property.
1045+
// - 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)
1046+
if (arrayWithoutNull.Length > 1)
1047+
{
1048+
// If the schema doesn't already have anyOf/oneOf, we can write multiple types as such.
1049+
var canWriteAsAnyOf = AnyOf is not { Count: > 0 };
1050+
var canWriteAsOneOf = OneOf is not { Count: > 0 };
1051+
if (canWriteAsAnyOf)
10181052
{
1019-
writer.WriteProperty(OpenApiConstants.Type, typeWithoutNull.ToFirstIdentifier());
1053+
writer.WriteOptionalCollection(OpenApiConstants.AnyOf, ConstructChildSchemasForTypes(arrayWithoutNull, hasNull), callback);
10201054
return;
10211055
}
1022-
break;
1023-
default:
1024-
WriteUnifiedSchemaType(typeToUse.Value, writer);
1056+
else if (canWriteAsOneOf)
1057+
{
1058+
writer.WriteOptionalCollection(OpenApiConstants.OneOf, ConstructChildSchemasForTypes(arrayWithoutNull, hasNull), callback);
1059+
return;
1060+
}
1061+
}
1062+
else if (arrayWithoutNull.Length == 1)
1063+
{
1064+
writer.WriteProperty(OpenApiConstants.Type, arrayWithoutNull[0].ToSingleIdentifier());
10251065
return;
1066+
}
1067+
}
1068+
else
1069+
{
1070+
var array = (from JsonSchemaType flag in jsonSchemaTypeValues
1071+
where type.HasFlag(flag)
1072+
select flag).ToArray();
1073+
1074+
if (array.Length > 1)
1075+
{
1076+
writer.WriteOptionalCollection(OpenApiConstants.Type, array, (w, s) => w.WriteValue(s.ToSingleIdentifier()));
1077+
}
1078+
else if (array.Length == 1)
1079+
{
1080+
writer.WriteProperty(OpenApiConstants.Type, array[0].ToSingleIdentifier());
1081+
}
10261082
}
10271083

1028-
return;
1084+
static OpenApiSchema[] ConstructChildSchemasForTypes(JsonSchemaType[] types, bool hasNull)
1085+
{
1086+
var schemas = new OpenApiSchema[types.Length + (hasNull ? 1 : 0)];
1087+
for (int i = 0; i < types.Length; i++)
1088+
{
1089+
schemas[i] = new OpenApiSchema()
1090+
{
1091+
Type = types[i]
1092+
};
1093+
}
1094+
1095+
if (hasNull)
1096+
{
1097+
schemas[schemas.Length - 1] = new OpenApiSchema() { Type = JsonSchemaType.Null };
1098+
}
1099+
1100+
return schemas;
1101+
}
10291102
}
10301103

10311104
private JsonNode? GetCompatibilityExample()
@@ -1063,27 +1136,6 @@ private static bool HasMultipleTypes(JsonSchemaType schemaType)
10631136
return !IsPowerOfTwo(schemaTypeNumeric);
10641137
}
10651138

1066-
private static void WriteUnifiedSchemaType(JsonSchemaType type, IOpenApiWriter writer)
1067-
{
1068-
var array = (from JsonSchemaType flag in jsonSchemaTypeValues
1069-
where type.HasFlag(flag)
1070-
select flag.ToFirstIdentifier()).ToArray();
1071-
if (array.Length > 1)
1072-
{
1073-
writer.WriteOptionalCollection(OpenApiConstants.Type, array, (w, s) =>
1074-
{
1075-
if (!string.IsNullOrEmpty(s) && s is not null)
1076-
{
1077-
w.WriteValue(s);
1078-
}
1079-
});
1080-
}
1081-
else
1082-
{
1083-
writer.WriteProperty(OpenApiConstants.Type, array[0]);
1084-
}
1085-
}
1086-
10871139
private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version)
10881140
{
10891141
if (HasNullType)

src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,113 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum
420420
schema.Type = JsonSchemaType.Null;
421421
}
422422

423+
if (schema.Type is null)
424+
{
425+
if (schema.AnyOf is not null &&
426+
schema.AnyOf.All(s => s is OpenApiSchema child && DoesSchemaRepresentSingleType(child)))
427+
{
428+
JsonSchemaType types = GetAllTypes(schema.AnyOf);
429+
schema.AnyOf = null;
430+
schema.Type = types;
431+
}
432+
else if (schema.OneOf is not null &&
433+
schema.OneOf.All(s => s is OpenApiSchema child && DoesSchemaRepresentSingleType(child)))
434+
{
435+
JsonSchemaType types = GetAllTypes(schema.OneOf);
436+
schema.OneOf = null;
437+
schema.Type = types;
438+
}
439+
}
440+
423441
return schema;
424442
}
443+
444+
private static JsonSchemaType GetAllTypes(IList<IOpenApiSchema> schemas)
445+
{
446+
JsonSchemaType types = 0;
447+
foreach (var schema in schemas)
448+
{
449+
types |= schema.Type!.Value;
450+
}
451+
452+
return types;
453+
}
454+
455+
private static bool DoesSchemaRepresentSingleType(OpenApiSchema schema)
456+
{
457+
if (schema.Type is not (JsonSchemaType.Null or
458+
JsonSchemaType.Boolean or
459+
JsonSchemaType.Integer or
460+
JsonSchemaType.Number or
461+
JsonSchemaType.String or
462+
JsonSchemaType.Object or
463+
JsonSchemaType.Array))
464+
{
465+
return false;
466+
}
467+
468+
// Folding anyOf/oneOf back into a single "type" is only safe when the child
469+
// schema carries nothing but its type. Otherwise any additional keywords
470+
// (format, bounds, enum, nested schemas, etc.) would be silently dropped.
471+
// Metadata is intentionally ignored as it only holds internal bookkeeping.
472+
return schema.Title is null &&
473+
schema.Schema is null &&
474+
schema.Id is null &&
475+
schema.Comment is null &&
476+
schema.Vocabulary is null &&
477+
schema.DynamicRef is null &&
478+
schema.DynamicAnchor is null &&
479+
schema.Definitions is null &&
480+
schema.Anchor is null &&
481+
schema.Format is null &&
482+
schema.Description is null &&
483+
schema.Maximum is null &&
484+
schema.Minimum is null &&
485+
schema.ExclusiveMaximum is null &&
486+
schema.ExclusiveMinimum is null &&
487+
schema.MaxLength is null &&
488+
schema.MinLength is null &&
489+
schema.Pattern is null &&
490+
schema.MultipleOf is null &&
491+
schema.Default is null &&
492+
!schema.ReadOnly &&
493+
!schema.WriteOnly &&
494+
schema.AllOf is null &&
495+
schema.OneOf is null &&
496+
schema.AnyOf is null &&
497+
schema.Not is null &&
498+
schema.Required is null &&
499+
schema.Items is null &&
500+
schema.MaxItems is null &&
501+
schema.MinItems is null &&
502+
schema.UniqueItems is null &&
503+
schema.Contains is null &&
504+
schema.MaxContains is null &&
505+
schema.MinContains is null &&
506+
schema.Properties is null &&
507+
schema.PatternProperties is null &&
508+
schema.MaxProperties is null &&
509+
schema.MinProperties is null &&
510+
schema.AdditionalPropertiesAllowed &&
511+
schema.AdditionalProperties is null &&
512+
schema.Discriminator is null &&
513+
schema.Enum is null &&
514+
schema.UnevaluatedProperties &&
515+
schema.UnevaluatedPropertiesSchema is null &&
516+
schema.ContentEncoding is null &&
517+
schema.ContentMediaType is null &&
518+
schema.ContentSchema is null &&
519+
schema.PropertyNames is null &&
520+
schema.DependentSchemas is null &&
521+
schema.DependentRequired is null &&
522+
schema.If is null &&
523+
schema.Then is null &&
524+
schema.Else is null &&
525+
schema.ExternalDocs is null &&
526+
!schema.Deprecated &&
527+
schema.Xml is null &&
528+
schema.Extensions is null &&
529+
schema.UnrecognizedKeywords is null;
530+
}
425531
}
426532
}

0 commit comments

Comments
 (0)