diff --git a/src/OpenApi/perf/Microbenchmarks/OpenApiSchemaComparerBenchmark.cs b/src/OpenApi/perf/Microbenchmarks/OpenApiSchemaComparerBenchmark.cs new file mode 100644 index 000000000000..efd0db85a59f --- /dev/null +++ b/src/OpenApi/perf/Microbenchmarks/OpenApiSchemaComparerBenchmark.cs @@ -0,0 +1,63 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using BenchmarkDotNet.Attributes; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Interfaces; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi.Microbenchmarks; + +public class OpenApiSchemaComparerBenchmark +{ + [Params(1, 10, 100)] + public int ElementCount { get; set; } + + private OpenApiSchema _schema; + + [GlobalSetup(Target = nameof(OpenApiSchema_GetHashCode))] + public void OpenApiSchema_Setup() + { + _schema = new OpenApiSchema + { + AdditionalProperties = GenerateInnerSchema(), + AdditionalPropertiesAllowed = true, + AllOf = Enumerable.Range(0, ElementCount).Select(_ => GenerateInnerSchema()).ToList(), + AnyOf = Enumerable.Range(0, ElementCount).Select(_ => GenerateInnerSchema()).ToList(), + Deprecated = true, + Default = new OpenApiString("default"), + Description = "description", + Discriminator = new OpenApiDiscriminator(), + Example = new OpenApiString("example"), + ExclusiveMaximum = true, + ExclusiveMinimum = true, + Extensions = new Dictionary + { + ["key"] = new OpenApiString("value") + }, + ExternalDocs = new OpenApiExternalDocs(), + Enum = Enumerable.Range(0, ElementCount).Select(_ => (IOpenApiAny)new OpenApiString("enum")).ToList(), + OneOf = Enumerable.Range(0, ElementCount).Select(_ => GenerateInnerSchema()).ToList(), + }; + + static OpenApiSchema GenerateInnerSchema() => new OpenApiSchema + { + Properties = Enumerable.Range(0, 10).ToDictionary(i => i.ToString(CultureInfo.InvariantCulture), _ => new OpenApiSchema()), + Deprecated = true, + Default = new OpenApiString("default"), + Description = "description", + Example = new OpenApiString("example"), + Extensions = new Dictionary + { + ["key"] = new OpenApiString("value") + }, + }; + } + + [Benchmark] + public void OpenApiSchema_GetHashCode() + { + OpenApiSchemaComparer.Instance.GetHashCode(_schema); + } +} diff --git a/src/OpenApi/sample/Controllers/TestController.cs b/src/OpenApi/sample/Controllers/TestController.cs index 3bc34d97b99f..cf1fed79abb2 100644 --- a/src/OpenApi/sample/Controllers/TestController.cs +++ b/src/OpenApi/sample/Controllers/TestController.cs @@ -7,6 +7,7 @@ [ApiController] [Route("[controller]")] +[ApiExplorerSettings(GroupName = "controllers")] public class TestController : ControllerBase { [HttpGet] diff --git a/src/OpenApi/sample/Program.cs b/src/OpenApi/sample/Program.cs index 7053cdcb2395..9d8df2a2b470 100644 --- a/src/OpenApi/sample/Program.cs +++ b/src/OpenApi/sample/Program.cs @@ -1,6 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Frozen; +using System.Collections.Immutable; +using System.ComponentModel; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using Microsoft.OpenApi.Models; using Sample.Transformers; @@ -24,8 +28,10 @@ return Task.CompletedTask; }); }); +builder.Services.AddOpenApi("controllers"); builder.Services.AddOpenApi("responses"); builder.Services.AddOpenApi("forms"); +builder.Services.AddOpenApi("schemas-by-ref"); var app = builder.Build(); @@ -38,6 +44,9 @@ var forms = app.MapGroup("forms") .WithGroupName("forms"); +var schemas = app.MapGroup("schemas-by-ref") + .WithGroupName("schemas-by-ref"); + if (app.Environment.IsDevelopment()) { forms.DisableAntiforgery(); @@ -84,6 +93,22 @@ responses.MapGet("/triangle", () => new Triangle { Color = "red", Sides = 3, Hypotenuse = 5.0 }); responses.MapGet("/shape", () => new Shape { Color = "blue", Sides = 4 }); +schemas.MapGet("/typed-results", () => TypedResults.Ok(new Triangle { Color = "red", Sides = 3, Hypotenuse = 5.0 })); +schemas.MapGet("/multiple-results", Results, NotFound> () => Random.Shared.Next(0, 2) == 0 + ? TypedResults.Ok(new Triangle { Color = "red", Sides = 3, Hypotenuse = 5.0 }) + : TypedResults.NotFound("Item not found.")); +schemas.MapGet("/iresult-no-produces", () => Results.Ok(new Triangle { Color = "red", Sides = 3, Hypotenuse = 5.0 })); +schemas.MapGet("/iresult-with-produces", () => Results.Ok(new Triangle { Color = "red", Sides = 3, Hypotenuse = 5.0 })) + .Produces(200, "text/xml"); +schemas.MapGet("/primitives", ([Description("The ID associated with the Todo item.")] int id, [Description("The number of Todos to fetch")] int size) => { }); +schemas.MapGet("/product", (Product product) => TypedResults.Ok(product)); +schemas.MapGet("/account", (Account account) => TypedResults.Ok(account)); +schemas.MapPost("/array-of-ints", (int[] values) => values.Sum()); +schemas.MapPost("/list-of-ints", (List values) => values.Count); +schemas.MapPost("/ienumerable-of-ints", (IEnumerable values) => values.Count()); +schemas.MapGet("/dictionary-of-ints", () => new Dictionary { { "one", 1 }, { "two", 2 } }); +schemas.MapGet("/frozen-dictionary-of-ints", () => ImmutableDictionary.CreateRange(new Dictionary { { "one", 1 }, { "two", 2 } })); + app.MapControllers(); app.Run(); diff --git a/src/OpenApi/src/Comparers/OpenApiAnyComparer.cs b/src/OpenApi/src/Comparers/OpenApiAnyComparer.cs new file mode 100644 index 000000000000..7990446ab26e --- /dev/null +++ b/src/OpenApi/src/Comparers/OpenApiAnyComparer.cs @@ -0,0 +1,81 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using Microsoft.OpenApi.Any; + +namespace Microsoft.AspNetCore.OpenApi; + +internal sealed class OpenApiAnyComparer : IEqualityComparer +{ + public static OpenApiAnyComparer Instance { get; } = new OpenApiAnyComparer(); + + public bool Equals(IOpenApiAny? x, IOpenApiAny? y) + { + if (x is null && y is null) + { + return true; + } + if (x is null || y is null) + { + return false; + } + if (object.ReferenceEquals(x, y)) + { + return true; + } + + return x.AnyType == y.AnyType && + (x switch + { + OpenApiNull _ => y is OpenApiNull, + OpenApiArray arrayX => y is OpenApiArray arrayY && arrayX.SequenceEqual(arrayY, Instance), + OpenApiObject objectX => y is OpenApiObject objectY && objectX.Keys.Count == objectY.Keys.Count && objectX.Keys.All(key => objectY.ContainsKey(key) && Equals(objectX[key], objectY[key])), + OpenApiBinary binaryX => y is OpenApiBinary binaryY && binaryX.Value.SequenceEqual(binaryY.Value), + OpenApiInteger integerX => y is OpenApiInteger integerY && integerX.Value == integerY.Value, + OpenApiLong longX => y is OpenApiLong longY && longX.Value == longY.Value, + OpenApiDouble doubleX => y is OpenApiDouble doubleY && doubleX.Value == doubleY.Value, + OpenApiFloat floatX => y is OpenApiFloat floatY && floatX.Value == floatY.Value, + OpenApiBoolean booleanX => y is OpenApiBoolean booleanY && booleanX.Value == booleanY.Value, + OpenApiString stringX => y is OpenApiString stringY && stringX.Value == stringY.Value, + OpenApiPassword passwordX => y is OpenApiPassword passwordY && passwordX.Value == passwordY.Value, + OpenApiByte byteX => y is OpenApiByte byteY && byteX.Value.SequenceEqual(byteY.Value), + OpenApiDate dateX => y is OpenApiDate dateY && dateX.Value == dateY.Value, + OpenApiDateTime dateTimeX => y is OpenApiDateTime dateTimeY && dateTimeX.Value == dateTimeY.Value, + _ => x.Equals(y) + }); + } + + public int GetHashCode(IOpenApiAny obj) + { + var hashCode = new HashCode(); + hashCode.Add(obj.AnyType); + if (obj is IOpenApiPrimitive primitive) + { + hashCode.Add(primitive.PrimitiveType); + } + if (obj is OpenApiBinary binary) + { + hashCode.AddBytes(binary.Value); + } + if (obj is OpenApiByte bytes) + { + hashCode.AddBytes(bytes.Value); + } + hashCode.Add(obj switch + { + OpenApiInteger integer => integer.Value, + OpenApiLong @long => @long.Value, + OpenApiDouble @double => @double.Value, + OpenApiFloat @float => @float.Value, + OpenApiBoolean boolean => boolean.Value, + OpenApiString @string => @string.Value, + OpenApiPassword password => password.Value, + OpenApiDate date => date.Value, + OpenApiDateTime dateTime => dateTime.Value, + _ => null + }); + + return hashCode.ToHashCode(); + } +} diff --git a/src/OpenApi/src/Comparers/OpenApiDiscriminatorComparer.cs b/src/OpenApi/src/Comparers/OpenApiDiscriminatorComparer.cs new file mode 100644 index 000000000000..f81c13589b44 --- /dev/null +++ b/src/OpenApi/src/Comparers/OpenApiDiscriminatorComparer.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi; + +internal sealed class OpenApiDiscriminatorComparer : IEqualityComparer +{ + public static OpenApiDiscriminatorComparer Instance { get; } = new OpenApiDiscriminatorComparer(); + + public bool Equals(OpenApiDiscriminator? x, OpenApiDiscriminator? y) + { + if (x is null && y is null) + { + return true; + } + if (x is null || y is null) + { + return false; + } + if (object.ReferenceEquals(x, y)) + { + return true; + } + + return x.PropertyName == y.PropertyName && + x.Mapping.Count == y.Mapping.Count && + x.Mapping.Keys.All(key => y.Mapping.ContainsKey(key) && x.Mapping[key] == y.Mapping[key]); + } + + public int GetHashCode(OpenApiDiscriminator obj) + { + var hashCode = new HashCode(); + hashCode.Add(obj.PropertyName); + hashCode.Add(obj.Mapping.Count); + return hashCode.ToHashCode(); + } +} diff --git a/src/OpenApi/src/Comparers/OpenApiExternalDocsComparer.cs b/src/OpenApi/src/Comparers/OpenApiExternalDocsComparer.cs new file mode 100644 index 000000000000..493b5e154be3 --- /dev/null +++ b/src/OpenApi/src/Comparers/OpenApiExternalDocsComparer.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi; + +internal sealed class OpenApiExternalDocsComparer : IEqualityComparer +{ + public static OpenApiExternalDocsComparer Instance { get; } = new OpenApiExternalDocsComparer(); + + public bool Equals(OpenApiExternalDocs? x, OpenApiExternalDocs? y) + { + if (x is null && y is null) + { + return true; + } + if (x is null || y is null) + { + return false; + } + if (object.ReferenceEquals(x, y)) + { + return true; + } + + return x.Description == y.Description && + x.Url == y.Url && + x.Extensions.Count == y.Extensions.Count + && x.Extensions.Keys.All(k => y.Extensions.ContainsKey(k) && y.Extensions[k] == x.Extensions[k]); + } + + public int GetHashCode(OpenApiExternalDocs obj) + { + var hashCode = new HashCode(); + hashCode.Add(obj.Description); + hashCode.Add(obj.Url); + hashCode.Add(obj.Extensions.Count); + return hashCode.ToHashCode(); + } +} diff --git a/src/OpenApi/src/Comparers/OpenApiReferenceComparer.cs b/src/OpenApi/src/Comparers/OpenApiReferenceComparer.cs new file mode 100644 index 000000000000..f4c95dfb1d14 --- /dev/null +++ b/src/OpenApi/src/Comparers/OpenApiReferenceComparer.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi; + +internal sealed class OpenApiReferenceComparer : IEqualityComparer +{ + public static OpenApiReferenceComparer Instance { get; } = new OpenApiReferenceComparer(); + + public bool Equals(OpenApiReference? x, OpenApiReference? y) + { + if (x is null && y is null) + { + return true; + } + if (x is null || y is null) + { + return false; + } + if (object.ReferenceEquals(x, y)) + { + return true; + } + + return x.ExternalResource == y.ExternalResource && + x.HostDocument?.HashCode == y.HostDocument?.HashCode && + x.Id == y.Id && + x.Type == y.Type; + } + + public int GetHashCode(OpenApiReference obj) + { + var hashCode = new HashCode(); + hashCode.Add(obj.ExternalResource); + hashCode.Add(obj.Id); + if (obj.Type is not null) + { + hashCode.Add(obj.Type); + } + return hashCode.ToHashCode(); + } +} diff --git a/src/OpenApi/src/Comparers/OpenApiSchemaComparer.cs b/src/OpenApi/src/Comparers/OpenApiSchemaComparer.cs new file mode 100644 index 000000000000..daf42772d89c --- /dev/null +++ b/src/OpenApi/src/Comparers/OpenApiSchemaComparer.cs @@ -0,0 +1,115 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi; + +internal sealed class OpenApiSchemaComparer : IEqualityComparer +{ + public static OpenApiSchemaComparer Instance { get; } = new OpenApiSchemaComparer(); + + public bool Equals(OpenApiSchema? x, OpenApiSchema? y) + { + if (x is null && y is null) + { + return true; + } + if (x is null || y is null) + { + return false; + } + if (object.ReferenceEquals(x, y)) + { + return true; + } + + return Instance.Equals(x.AdditionalProperties, y.AdditionalProperties) && + x.AdditionalPropertiesAllowed == y.AdditionalPropertiesAllowed && + x.AllOf.SequenceEqual(y.AllOf, Instance) && + x.AnyOf.SequenceEqual(y.AnyOf, Instance) && + x.Deprecated == y.Deprecated && + OpenApiAnyComparer.Instance.Equals(x.Default, y.Default) && + x.Description == y.Description && + OpenApiDiscriminatorComparer.Instance.Equals(x.Discriminator, y.Discriminator) && + OpenApiAnyComparer.Instance.Equals(x.Example, y.Example) && + x.ExclusiveMaximum == y.ExclusiveMaximum && + x.ExclusiveMinimum == y.ExclusiveMinimum && + x.Extensions.Count == y.Extensions.Count + && x.Extensions.Keys.All(k => y.Extensions.ContainsKey(k) && x.Extensions[k] is IOpenApiAny anyX && y.Extensions[k] is IOpenApiAny anyY && OpenApiAnyComparer.Instance.Equals(anyX, anyY)) && + OpenApiExternalDocsComparer.Instance.Equals(x.ExternalDocs, y.ExternalDocs) && + x.Enum.SequenceEqual(y.Enum, OpenApiAnyComparer.Instance) && + x.Format == y.Format && + Instance.Equals(x.Items, y.Items) && + x.Title == y.Title && + x.Type == y.Type && + x.Maximum == y.Maximum && + x.MaxItems == y.MaxItems && + x.MaxLength == y.MaxLength && + x.MaxProperties == y.MaxProperties && + x.Minimum == y.Minimum && + x.MinItems == y.MinItems && + x.MinLength == y.MinLength && + x.MinProperties == y.MinProperties && + x.MultipleOf == y.MultipleOf && + x.OneOf.SequenceEqual(y.OneOf, Instance) && + Instance.Equals(x.Not, y.Not) && + x.Nullable == y.Nullable && + x.Pattern == y.Pattern && + x.Properties.Keys.All(k => y.Properties.ContainsKey(k) && Instance.Equals(x.Properties[k], y.Properties[k])) && + x.ReadOnly == y.ReadOnly && + x.Required.Order().SequenceEqual(y.Required.Order()) && + OpenApiReferenceComparer.Instance.Equals(x.Reference, y.Reference) && + x.UniqueItems == y.UniqueItems && + x.UnresolvedReference == y.UnresolvedReference && + x.WriteOnly == y.WriteOnly && + OpenApiXmlComparer.Instance.Equals(x.Xml, y.Xml); + } + + public int GetHashCode(OpenApiSchema obj) + { + var hashCode = new HashCode(); + hashCode.Add(obj.AdditionalProperties, Instance); + hashCode.Add(obj.AdditionalPropertiesAllowed); + hashCode.Add(obj.AllOf.Count); + hashCode.Add(obj.AnyOf.Count); + hashCode.Add(obj.Deprecated); + hashCode.Add(obj.Default, OpenApiAnyComparer.Instance); + hashCode.Add(obj.Description); + hashCode.Add(obj.Discriminator, OpenApiDiscriminatorComparer.Instance); + hashCode.Add(obj.Example, OpenApiAnyComparer.Instance); + hashCode.Add(obj.ExclusiveMaximum); + hashCode.Add(obj.ExclusiveMinimum); + hashCode.Add(obj.Extensions.Count); + hashCode.Add(obj.ExternalDocs, OpenApiExternalDocsComparer.Instance); + hashCode.Add(obj.Enum.Count); + hashCode.Add(obj.Format); + hashCode.Add(obj.Items, Instance); + hashCode.Add(obj.Title); + hashCode.Add(obj.Type); + hashCode.Add(obj.Maximum); + hashCode.Add(obj.MaxItems); + hashCode.Add(obj.MaxLength); + hashCode.Add(obj.MaxProperties); + hashCode.Add(obj.Minimum); + hashCode.Add(obj.MinItems); + hashCode.Add(obj.MinLength); + hashCode.Add(obj.MinProperties); + hashCode.Add(obj.MultipleOf); + hashCode.Add(obj.OneOf.Count); + hashCode.Add(obj.Not, Instance); + hashCode.Add(obj.Nullable); + hashCode.Add(obj.Pattern); + hashCode.Add(obj.Properties.Count); + hashCode.Add(obj.ReadOnly); + hashCode.Add(obj.Required.Count); + hashCode.Add(obj.Reference, OpenApiReferenceComparer.Instance); + hashCode.Add(obj.UniqueItems); + hashCode.Add(obj.UnresolvedReference); + hashCode.Add(obj.WriteOnly); + hashCode.Add(obj.Xml, OpenApiXmlComparer.Instance); + return hashCode.ToHashCode(); + } +} diff --git a/src/OpenApi/src/Helpers/OpenApiTagComparer.cs b/src/OpenApi/src/Comparers/OpenApiTagComparer.cs similarity index 87% rename from src/OpenApi/src/Helpers/OpenApiTagComparer.cs rename to src/OpenApi/src/Comparers/OpenApiTagComparer.cs index d24d12e79768..7154553e932c 100644 --- a/src/OpenApi/src/Helpers/OpenApiTagComparer.cs +++ b/src/OpenApi/src/Comparers/OpenApiTagComparer.cs @@ -9,7 +9,7 @@ namespace Microsoft.AspNetCore.OpenApi; /// This comparer is used to maintain a globally unique list of tags encountered /// in a particular OpenAPI document. /// -internal class OpenApiTagComparer : IEqualityComparer +internal sealed class OpenApiTagComparer : IEqualityComparer { public static OpenApiTagComparer Instance { get; } = new OpenApiTagComparer(); @@ -23,6 +23,11 @@ public bool Equals(OpenApiTag? x, OpenApiTag? y) { return false; } + if (object.ReferenceEquals(x, y)) + { + return true; + } + // Tag comparisons are case-sensitive by default. Although the OpenAPI specification // only outlines case sensitivity for property names, we extend this principle to // property values for tag names as well. diff --git a/src/OpenApi/src/Comparers/OpenApiXmlComparer.cs b/src/OpenApi/src/Comparers/OpenApiXmlComparer.cs new file mode 100644 index 000000000000..4ae4d9ea3ddd --- /dev/null +++ b/src/OpenApi/src/Comparers/OpenApiXmlComparer.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi; + +internal sealed class OpenApiXmlComparer : IEqualityComparer +{ + public static OpenApiXmlComparer Instance { get; } = new OpenApiXmlComparer(); + + public bool Equals(OpenApiXml? x, OpenApiXml? y) + { + if (x is null && y is null) + { + return true; + } + if (x is null || y is null) + { + return false; + } + if (object.ReferenceEquals(x, y)) + { + return true; + } + + return x.Name == y.Name && + x.Namespace == y.Namespace && + x.Prefix == y.Prefix && + x.Attribute == y.Attribute && + x.Wrapped == y.Wrapped && + x.Extensions.Count == y.Extensions.Count + && x.Extensions.Keys.All(k => y.Extensions.ContainsKey(k) && y.Extensions[k] == x.Extensions[k]); + } + + public int GetHashCode(OpenApiXml obj) + { + var hashCode = new HashCode(); + hashCode.Add(obj.Name); + hashCode.Add(obj.Namespace); + hashCode.Add(obj.Prefix); + hashCode.Add(obj.Attribute); + hashCode.Add(obj.Wrapped); + hashCode.Add(obj.Extensions.Count); + return hashCode.ToHashCode(); + } +} diff --git a/src/OpenApi/src/Extensions/JsonObjectSchemaExtensions.cs b/src/OpenApi/src/Extensions/JsonObjectSchemaExtensions.cs index 6b73c4d90df5..59e713014ff5 100644 --- a/src/OpenApi/src/Extensions/JsonObjectSchemaExtensions.cs +++ b/src/OpenApi/src/Extensions/JsonObjectSchemaExtensions.cs @@ -164,14 +164,15 @@ internal static void ApplyDefaultValue(this JsonObject schema, object? defaultVa /// opposed to after the generated schemas have been mapped to OpenAPI schemas. /// /// The produced by the underlying schema generator. - /// The associated with the . - internal static void ApplyPrimitiveTypesAndFormats(this JsonObject schema, Type type) + /// The associated with the . + internal static void ApplyPrimitiveTypesAndFormats(this JsonObject schema, JsonSchemaGenerationContext context) { - if (_simpleTypeToOpenApiSchema.TryGetValue(type, out var openApiSchema)) + if (_simpleTypeToOpenApiSchema.TryGetValue(context.TypeInfo.Type, out var openApiSchema)) { schema[OpenApiSchemaKeywords.NullableKeyword] = openApiSchema.Nullable || (schema[OpenApiSchemaKeywords.TypeKeyword] is JsonArray schemaType && schemaType.GetValues().Contains("null")); schema[OpenApiSchemaKeywords.TypeKeyword] = openApiSchema.Type; schema[OpenApiSchemaKeywords.FormatKeyword] = openApiSchema.Format; + schema[OpenApiConstants.SchemaId] = context.TypeInfo.GetSchemaReferenceId(); } } @@ -324,4 +325,14 @@ internal static void ApplyPolymorphismOptions(this JsonObject schema, JsonSchema schema[OpenApiSchemaKeywords.DiscriminatorMappingKeyword] = mappings; } } + + /// + /// Set the x-schema-id property on the schema to the identifier associated with the type. + /// + /// The produced by the underlying schema generator. + /// The associated with the current type. + internal static void ApplySchemaReferenceId(this JsonObject schema, JsonSchemaGenerationContext context) + { + schema[OpenApiConstants.SchemaId] = context.TypeInfo.GetSchemaReferenceId(); + } } diff --git a/src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs b/src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs new file mode 100644 index 000000000000..fa5cc8e119e1 --- /dev/null +++ b/src/OpenApi/src/Extensions/JsonTypeInfoExtensions.cs @@ -0,0 +1,121 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO.Pipelines; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.OpenApi; + +internal static class JsonTypeInfoExtensions +{ + private static readonly Dictionary _simpleTypeToName = new() + { + [typeof(bool)] = "boolean", + [typeof(byte)] = "byte", + [typeof(int)] = "int", + [typeof(uint)] = "uint", + [typeof(long)] = "long", + [typeof(ulong)] = "ulong", + [typeof(short)] = "short", + [typeof(ushort)] = "ushort", + [typeof(float)] = "float", + [typeof(double)] = "double", + [typeof(decimal)] = "decimal", + [typeof(DateTime)] = "DateTime", + [typeof(DateTimeOffset)] = "DateTimeOffset", + [typeof(Guid)] = "Guid", + [typeof(char)] = "char", + [typeof(Uri)] = "Uri", + [typeof(string)] = "string", + [typeof(IFormFile)] = "IFormFile", + [typeof(IFormFileCollection)] = "IFormFileCollection", + [typeof(PipeReader)] = "PipeReader", + [typeof(Stream)] = "Stream" + }; + + /// + /// The following method maps a JSON type to a schema reference ID that will eventually be used as the + /// schema reference name in the OpenAPI document. These schema reference names are considered URL fragments + /// in the context of JSON Schema's $ref keyword and must comply with the character restrictions of URL fragments. + /// In particular, the generated strings can contain alphanumeric characters and a subset of special symbols. This + /// means that certain symbols that appear commonly in .NET type names like ">" are not permitted in the + /// generated reference ID. + /// + /// The associated with the target schema. + /// The schema reference ID represented as a string name. + internal static string GetSchemaReferenceId(this JsonTypeInfo jsonTypeInfo) + { + var type = jsonTypeInfo.Type; + // Short-hand if the type we're generating a schema reference ID for is + // one of the simple types defined above. + if (_simpleTypeToName.TryGetValue(type, out var simpleName)) + { + return simpleName; + } + + if (jsonTypeInfo is JsonTypeInfo { Kind: JsonTypeInfoKind.Enumerable, ElementType: { } elementType }) + { + var elementTypeInfo = jsonTypeInfo.Options.GetTypeInfo(elementType); + return $"ArrayOf{elementTypeInfo.GetSchemaReferenceId()}"; + } + + if (jsonTypeInfo is JsonTypeInfo { Kind: JsonTypeInfoKind.Dictionary, KeyType: { } keyType, ElementType: { } valueType }) + { + var keyTypeInfo = jsonTypeInfo.Options.GetTypeInfo(keyType); + var valueTypeInfo = jsonTypeInfo.Options.GetTypeInfo(valueType); + return $"DictionaryOf{keyTypeInfo.GetSchemaReferenceId()}And{valueTypeInfo.GetSchemaReferenceId()}"; + } + + return type.GetSchemaReferenceId(jsonTypeInfo.Options); + } + + internal static string GetSchemaReferenceId(this Type type, JsonSerializerOptions options) + { + // Check the simple types map first to account for the element types + // of enumerables that have been processed above. + if (_simpleTypeToName.TryGetValue(type, out var simpleName)) + { + return simpleName; + } + + // Although arrays are enumerable types they are not encoded correctly + // with JsonTypeInfoKind.Enumerable so we handle that here + if (type.IsArray && type.GetElementType() is { } elementType) + { + var elementTypeInfo = options.GetTypeInfo(elementType); + return $"ArrayOf{elementTypeInfo.GetSchemaReferenceId()}"; + } + + // Special handling for anonymous types + if (type.Name.StartsWith("<>f", StringComparison.Ordinal)) + { + var typeName = "AnonymousType"; + var anonymousTypeProperties = type.GetGenericArguments(); + var propertyNames = string.Join("And", anonymousTypeProperties.Select(p => p.GetSchemaReferenceId(options))); + return $"{typeName}Of{propertyNames}"; + } + + if (type.IsGenericType) + { + // Nullable types are suffixed with `?` (e.g. `Todo?`) + if (type.GetGenericTypeDefinition() == typeof(Nullable<>) + && Nullable.GetUnderlyingType(type) is { } underlyingType) + { + return $"{underlyingType.GetSchemaReferenceId(options)}?"; + } + // Special handling for generic types that are collections + // Generic types become a concatenation of the generic type name and the type arguments + else + { + var genericTypeName = type.Name[..type.Name.LastIndexOf('`')]; + var genericArguments = type.GetGenericArguments(); + var argumentNames = string.Join("And", genericArguments.Select(arg => arg.GetSchemaReferenceId(options))); + return $"{genericTypeName}Of{argumentNames}"; + } + } + return type.Name; + } +} diff --git a/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs b/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs index 7493f4313732..aa9fa15626fe 100644 --- a/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs +++ b/src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.OpenApi; using Microsoft.OpenApi.Any; using Microsoft.OpenApi.Models; +using OpenApiConstants = Microsoft.AspNetCore.OpenApi.OpenApiConstants; internal sealed partial class OpenApiJsonSchema { @@ -289,6 +290,10 @@ public static void ReadProperty(ref Utf8JsonReader reader, string propertyName, var mappings = ReadDictionary(ref reader); schema.Discriminator.Mapping = mappings; break; + case OpenApiConstants.SchemaId: + reader.Read(); + schema.Extensions.Add(OpenApiConstants.SchemaId, new OpenApiString(reader.GetString())); + break; default: reader.Skip(); break; diff --git a/src/OpenApi/src/Schemas/OpenApiSchemaKeywords.cs b/src/OpenApi/src/Schemas/OpenApiSchemaKeywords.cs index 69ba96d77016..1c41c47e1973 100644 --- a/src/OpenApi/src/Schemas/OpenApiSchemaKeywords.cs +++ b/src/OpenApi/src/Schemas/OpenApiSchemaKeywords.cs @@ -23,4 +23,6 @@ internal class OpenApiSchemaKeywords public const string MaximumKeyword = "maximum"; public const string MinItemsKeyword = "minItems"; public const string MaxItemsKeyword = "maxItems"; + public const string RefKeyword = "$ref"; + public const string SchemaIdKeyword = "x-schema-id"; } diff --git a/src/OpenApi/src/Services/OpenApiConstants.cs b/src/OpenApi/src/Services/OpenApiConstants.cs index 28adba3f51d5..d0adb4e3d797 100644 --- a/src/OpenApi/src/Services/OpenApiConstants.cs +++ b/src/OpenApi/src/Services/OpenApiConstants.cs @@ -11,6 +11,7 @@ internal static class OpenApiConstants internal const string DefaultOpenApiVersion = "1.0.0"; internal const string DefaultOpenApiRoute = "/openapi/{documentName}.json"; internal const string DescriptionId = "x-aspnetcore-id"; + internal const string SchemaId = "x-schema-id"; internal const string DefaultOpenApiResponseKey = "default"; // Since there's a finite set of operation types that can be included in a given // OpenApiPaths, we can pre-allocate an array of these types and use a direct diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index d436cd6eb3c7..7e7ed1ef53a2 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Collections.Concurrent; using System.ComponentModel.DataAnnotations; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; @@ -34,6 +33,7 @@ internal sealed class OpenApiDocumentService( private readonly OpenApiOptions _options = optionsMonitor.Get(documentName); private readonly OpenApiSchemaService _componentService = serviceProvider.GetRequiredKeyedService(documentName); private readonly IOpenApiDocumentTransformer _scrubExtensionsTransformer = new ScrubExtensionsTransformer(); + private readonly IOpenApiDocumentTransformer _schemaReferenceTransformer = new OpenApiSchemaReferenceTransformer(); private static readonly OpenApiEncoding _defaultFormEncoding = new OpenApiEncoding { Style = ParameterStyle.Form, Explode = true }; @@ -43,7 +43,7 @@ internal sealed class OpenApiDocumentService( /// are unique within the lifetime of an application and serve as helpful associators between /// operations, API descriptions, and their respective transformer contexts. /// - private readonly ConcurrentDictionary _operationTransformerContextCache = new(); + private readonly Dictionary _operationTransformerContextCache = new(); private static readonly ApiResponseType _defaultApiResponseType = new ApiResponseType { StatusCode = StatusCodes.Status200OK }; internal bool TryGetCachedOperationTransformerContext(string descriptionId, [NotNullWhen(true)] out OpenApiOperationTransformerContext? context) @@ -78,7 +78,9 @@ private async Task ApplyTransformersAsync(OpenApiDocument document, Cancellation var transformer = _options.DocumentTransformers[i]; await transformer.TransformAsync(document, documentTransformerContext, cancellationToken); } - // Remove `x-aspnetcore-id` extension from operations after all transformers have run. + // Move duplicated JSON schemas to the global components.schemas object and map references after all transformers have run. + await _schemaReferenceTransformer.TransformAsync(document, documentTransformerContext, cancellationToken); + // Remove `x-aspnetcore-id` and `x-schema-id` extensions from operations after all transformers have run. await _scrubExtensionsTransformer.TransformAsync(document, documentTransformerContext, cancellationToken); } diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index 81f91b3c8c51..d51f8d32c80c 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -76,7 +76,8 @@ internal sealed class OpenApiSchemaService( [OpenApiSchemaKeywords.FormatKeyword] = "binary" }; } - schema.ApplyPrimitiveTypesAndFormats(type); + schema.ApplyPrimitiveTypesAndFormats(context); + schema.ApplySchemaReferenceId(context); if (context.GetCustomAttributes(typeof(ValidationAttribute)) is { } validationAttributes) { schema.ApplyValidationAttributes(validationAttributes); @@ -102,6 +103,7 @@ internal async Task GetOrCreateSchemaAsync(Type type, ApiParamete Debug.Assert(deserializedSchema != null, "The schema should have been deserialized successfully and materialize a non-null value."); var schema = deserializedSchema.Schema; await ApplySchemaTransformersAsync(schema, type, parameterDescription, cancellationToken); + _schemaStore.PopulateSchemaIntoReferenceCache(schema); return schema; } diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaStore.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaStore.cs index b37466ad0bae..4d27ee5792e9 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaStore.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaStore.cs @@ -1,10 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Collections.Concurrent; using System.IO.Pipelines; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Http; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; namespace Microsoft.AspNetCore.OpenApi; @@ -14,19 +15,43 @@ namespace Microsoft.AspNetCore.OpenApi; /// internal sealed class OpenApiSchemaStore { - private readonly ConcurrentDictionary _schemas = new() + private readonly Dictionary _schemas = new() { // Pre-populate OpenAPI schemas for well-defined types in ASP.NET Core. - [new OpenApiSchemaKey(typeof(IFormFile), null)] = new JsonObject { ["type"] = "string", ["format"] = "binary" }, + [new OpenApiSchemaKey(typeof(IFormFile), null)] = new JsonObject + { + ["type"] = "string", + ["format"] = "binary", + [OpenApiConstants.SchemaId] = "IFormFile" + }, [new OpenApiSchemaKey(typeof(IFormFileCollection), null)] = new JsonObject { ["type"] = "array", - ["items"] = new JsonObject { ["type"] = "string", ["format"] = "binary" } + ["items"] = new JsonObject + { + ["type"] = "string", + ["format"] = "binary", + [OpenApiConstants.SchemaId] = "IFormFile" + }, + [OpenApiConstants.SchemaId] = "IFormFileCollection" + }, + [new OpenApiSchemaKey(typeof(Stream), null)] = new JsonObject + { + ["type"] = "string", + ["format"] = "binary", + [OpenApiConstants.SchemaId] = "Stream" + }, + [new OpenApiSchemaKey(typeof(PipeReader), null)] = new JsonObject + { + ["type"] = "string", + ["format"] = "binary", + [OpenApiConstants.SchemaId] = "PipeReader" }, - [new OpenApiSchemaKey(typeof(Stream), null)] = new JsonObject { ["type"] = "string", ["format"] = "binary" }, - [new OpenApiSchemaKey(typeof(PipeReader), null)] = new JsonObject { ["type"] = "string", ["format"] = "binary" }, }; + public readonly Dictionary SchemasByReference = new(OpenApiSchemaComparer.Instance); + private readonly Dictionary _referenceIdCounter = new(); + /// /// Resolves the JSON schema for the given type and parameter description. /// @@ -35,6 +60,120 @@ internal sealed class OpenApiSchemaStore /// A representing the JSON schema associated with the key. public JsonObject GetOrAdd(OpenApiSchemaKey key, Func valueFactory) { - return _schemas.GetOrAdd(key, valueFactory); + if (_schemas.TryGetValue(key, out var schema)) + { + return schema; + } + var targetSchema = valueFactory(key); + _schemas.Add(key, targetSchema); + return targetSchema; + } + + /// + /// Add the provided schema to the schema-with-references cache that is eventually + /// used to populate the top-level components.schemas object. This method will + /// unwrap the provided schema and add any child schemas to the global cache. Child + /// schemas include those referenced in the schema.Items, schema.AdditionalProperties, or + /// schema.Properties collections. Schema reference IDs are only set for schemas that have + /// been encountered more than once in the document to avoid unnecessarily capturing unique + /// schemas into the top-level document. + /// + /// The to add to the schemas-with-references cache. + public void PopulateSchemaIntoReferenceCache(OpenApiSchema schema) + { + AddOrUpdateSchemaByReference(schema); + if (schema.AdditionalProperties is not null) + { + AddOrUpdateSchemaByReference(schema.AdditionalProperties); + } + if (schema.Items is not null) + { + AddOrUpdateSchemaByReference(schema.Items); + } + if (schema.AllOf is not null) + { + foreach (var allOfSchema in schema.AllOf) + { + AddOrUpdateSchemaByReference(allOfSchema); + } + } + if (schema.AnyOf is not null) + { + foreach (var anyOfSchema in schema.AnyOf) + { + AddOrUpdateSchemaByReference(anyOfSchema); + } + } + if (schema.Properties is not null) + { + foreach (var property in schema.Properties.Values) + { + AddOrUpdateSchemaByReference(property); + } + } + } + + private void AddOrUpdateSchemaByReference(OpenApiSchema schema) + { + if (SchemasByReference.TryGetValue(schema, out var referenceId)) + { + // If we've already used this reference ID else where in the document, increment a counter value to the reference + // ID to avoid name collisions. These collisions are most likely to occur when the same .NET type produces a different + // schema in the OpenAPI document because of special annotations provided on it. For example, in the two type definitions + // below: + // public class Todo + // { + // public int Id { get; set; } + // public string Name { get; set; } + // } + // public class Project + // { + // public int Id { get; set; } + // [MinLength(5)] + // public string Title { get; set; } + // } + // The `Title` and `Name` properties are both strings but the `Title` property has a `minLength` annotation + // on it that will materialize into a different schema. + // { + // + // "type": "string", + // "minLength": 5 + // } + // { + // "type": "string" + // } + // In this case, although the reference ID based on the .NET type we would use is `string`, the + // two schemas are distinct. + if (referenceId == null) + { + var targetReferenceId = GetSchemaReferenceId(schema); + if (_referenceIdCounter.TryGetValue(targetReferenceId, out var counter)) + { + counter++; + _referenceIdCounter[targetReferenceId] = counter; + SchemasByReference[schema] = $"{targetReferenceId}{counter}"; + } + else + { + _referenceIdCounter[targetReferenceId] = 1; + SchemasByReference[schema] = targetReferenceId; + } + } + } + else + { + SchemasByReference[schema] = null; + } + } + + private static string GetSchemaReferenceId(OpenApiSchema schema) + { + if (schema.Extensions.TryGetValue(OpenApiConstants.SchemaId, out var referenceIdAny) + && referenceIdAny is OpenApiString { Value: string referenceId }) + { + return referenceId; + } + + throw new InvalidOperationException("The schema reference ID must be set on the schema."); } } diff --git a/src/OpenApi/src/Transformers/Implementations/OpenApiSchemaReferenceTransformer.cs b/src/OpenApi/src/Transformers/Implementations/OpenApiSchemaReferenceTransformer.cs new file mode 100644 index 000000000000..af9885d96b9a --- /dev/null +++ b/src/OpenApi/src/Transformers/Implementations/OpenApiSchemaReferenceTransformer.cs @@ -0,0 +1,152 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi; + +/// +/// Document transformer to support mapping duplicate JSON schema instances +/// into JSON schema references across the document. +/// +internal sealed class OpenApiSchemaReferenceTransformer : IOpenApiDocumentTransformer +{ + public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + var schemaStore = context.ApplicationServices.GetRequiredKeyedService(context.DocumentName); + var schemasByReference = schemaStore.SchemasByReference; + + document.Components ??= new OpenApiComponents(); + document.Components.Schemas ??= new Dictionary(); + + foreach (var (schema, referenceId) in schemasByReference.Where(kvp => kvp.Value is not null).OrderBy(kvp => kvp.Value)) + { + // Reference IDs are only set for schemas that appear more than once in the OpenAPI + // document and should be represented as references instead of inlined in the document. + if (referenceId is not null) + { + // Note: we create a copy of the schema here to avoid modifying the original schema + // so that comparisons between the original schema and the resolved schema during + // the transformation process are consistent. + document.Components.Schemas.Add( + referenceId, + ResolveReferenceForSchema(new OpenApiSchema(schema), schemasByReference, isTopLevel: true)); + } + } + + foreach (var pathItem in document.Paths.Values) + { + for (var i = 0; i < OpenApiConstants.OperationTypes.Length; i++) + { + var operationType = OpenApiConstants.OperationTypes[i]; + if (pathItem.Operations.TryGetValue(operationType, out var operation)) + { + if (operation.Parameters is not null) + { + foreach (var parameter in operation.Parameters) + { + parameter.Schema = ResolveReferenceForSchema(parameter.Schema, schemasByReference); + } + } + + if (operation.RequestBody is not null) + { + foreach (var content in operation.RequestBody.Content) + { + content.Value.Schema = ResolveReferenceForSchema(content.Value.Schema, schemasByReference); + } + } + + if (operation.Responses is not null) + { + foreach (var response in operation.Responses.Values) + { + if (response.Content is not null) + { + foreach (var content in response.Content) + { + content.Value.Schema = ResolveReferenceForSchema(content.Value.Schema, schemasByReference); + } + } + } + } + } + } + } + + return Task.CompletedTask; + } + + /// + /// Resolves the provided schema into a reference if it is found in the schemas-by-reference cache. + /// + /// The inline schema to replace with a reference. + /// A cache of schemas and their associated reference IDs. + /// When , will skip resolving references for the top-most schema provided. + internal static OpenApiSchema? ResolveReferenceForSchema(OpenApiSchema? schema, Dictionary schemasByReference, bool isTopLevel = false) + { + if (schema is null) + { + return schema; + } + + // If we're resolving schemas for a top-level schema being referenced in the `components.schema` property + // we don't want to replace the top-level inline schema with a reference to itself. We want to replace + // inline schemas to reference schemas for all schemas referenced in the top-level schema though (such as + // `allOf`, `oneOf`, `anyOf`, `items`, `properties`, etc.) which is why `isTopLevel` is only set once. + if (!isTopLevel && schemasByReference.TryGetValue(schema, out var referenceId) && referenceId is not null) + { + return new OpenApiSchema { Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = referenceId } }; + } + + if (schema.AllOf is not null) + { + for (var i = 0; i < schema.AllOf.Count; i++) + { + schema.AllOf[i] = ResolveReferenceForSchema(schema.AllOf[i], schemasByReference); + } + } + + if (schema.OneOf is not null) + { + for (var i = 0; i < schema.OneOf.Count; i++) + { + schema.OneOf[i] = ResolveReferenceForSchema(schema.OneOf[i], schemasByReference); + } + } + + if (schema.AnyOf is not null) + { + for (var i = 0; i < schema.AnyOf.Count; i++) + { + schema.AnyOf[i] = ResolveReferenceForSchema(schema.AnyOf[i], schemasByReference); + } + } + + if (schema.AdditionalProperties is not null) + { + schema.AdditionalProperties = ResolveReferenceForSchema(schema.AdditionalProperties, schemasByReference); + } + + if (schema.Items is not null) + { + schema.Items = ResolveReferenceForSchema(schema.Items, schemasByReference); + } + + if (schema.Properties is not null) + { + foreach (var property in schema.Properties) + { + schema.Properties[property.Key] = ResolveReferenceForSchema(property.Value, schemasByReference); + } + } + + if (schema.Not is not null) + { + schema.Not = ResolveReferenceForSchema(schema.Not, schemasByReference); + } + return schema; + } +} diff --git a/src/OpenApi/src/Transformers/Implementations/ScrubExtensionsTransformer.cs b/src/OpenApi/src/Transformers/Implementations/ScrubExtensionsTransformer.cs index e12fc41c25a0..d47f8242bc2b 100644 --- a/src/OpenApi/src/Transformers/Implementations/ScrubExtensionsTransformer.cs +++ b/src/OpenApi/src/Transformers/Implementations/ScrubExtensionsTransformer.cs @@ -24,8 +24,101 @@ public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerC } operation.Extensions.Remove(OpenApiConstants.DescriptionId); + + if (operation.Parameters is not null) + { + foreach (var parameter in operation.Parameters) + { + ScrubSchemaIdExtension(parameter.Schema); + } + } + + if (operation.RequestBody is not null) + { + foreach (var content in operation.RequestBody.Content) + { + ScrubSchemaIdExtension(content.Value.Schema); + } + } + + if (operation.Responses is not null) + { + foreach (var response in operation.Responses.Values) + { + if (response.Content is not null) + { + foreach (var content in response.Content) + { + ScrubSchemaIdExtension(content.Value.Schema); + } + } + } + } } } + + foreach (var schema in document.Components.Schemas.Values) + { + ScrubSchemaIdExtension(schema); + } + return Task.CompletedTask; } + + internal static void ScrubSchemaIdExtension(OpenApiSchema? schema) + { + if (schema is null) + { + return; + } + + if (schema.AllOf is not null) + { + for (var i = 0; i < schema.AllOf.Count; i++) + { + ScrubSchemaIdExtension(schema.AllOf[i]); + } + } + + if (schema.OneOf is not null) + { + for (var i = 0; i < schema.OneOf.Count; i++) + { + ScrubSchemaIdExtension(schema.OneOf[i]); + } + } + + if (schema.AnyOf is not null) + { + for (var i = 0; i < schema.AnyOf.Count; i++) + { + ScrubSchemaIdExtension(schema.AnyOf[i]); + } + } + + if (schema.AdditionalProperties is not null) + { + ScrubSchemaIdExtension(schema.AdditionalProperties); + } + + if (schema.Items is not null) + { + ScrubSchemaIdExtension(schema.Items); + } + + if (schema.Properties is not null) + { + foreach (var property in schema.Properties) + { + ScrubSchemaIdExtension(schema.Properties[property.Key]); + } + } + + if (schema.Not is not null) + { + ScrubSchemaIdExtension(schema.Not); + } + + schema.Extensions.Remove(OpenApiConstants.SchemaId); + } } diff --git a/src/OpenApi/test/Comparers/OpenApiAnyComparerTests.cs b/src/OpenApi/test/Comparers/OpenApiAnyComparerTests.cs new file mode 100644 index 000000000000..da0ea895fafe --- /dev/null +++ b/src/OpenApi/test/Comparers/OpenApiAnyComparerTests.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Any; + +public class OpenApiAnyComparerTests +{ + public static object[][] Data => [ + [new OpenApiNull(), new OpenApiNull(), true], + [new OpenApiNull(), new OpenApiBoolean(true), false], + [new OpenApiByte(1), new OpenApiByte(1), true], + [new OpenApiByte(1), new OpenApiByte(2), false], + [new OpenApiBinary(Encoding.UTF8.GetBytes("test")), new OpenApiBinary(Encoding.UTF8.GetBytes("test")), true], + [new OpenApiBinary(Encoding.UTF8.GetBytes("test2")), new OpenApiBinary(Encoding.UTF8.GetBytes("test")), false], + [new OpenApiBoolean(true), new OpenApiBoolean(true), true], + [new OpenApiBoolean(true), new OpenApiBoolean(false), false], + [new OpenApiInteger(1), new OpenApiInteger(1), true], + [new OpenApiInteger(1), new OpenApiInteger(2), false], + [new OpenApiInteger(1), new OpenApiLong(1), false], + [new OpenApiLong(1), new OpenApiLong(1), true], + [new OpenApiLong(1), new OpenApiLong(2), false], + [new OpenApiFloat(1.1f), new OpenApiFloat(1.1f), true], + [new OpenApiFloat(1.1f), new OpenApiFloat(1.2f), false], + [new OpenApiDouble(1.1), new OpenApiDouble(1.1), true], + [new OpenApiDouble(1.1), new OpenApiDouble(1.2), false], + [new OpenApiString("value"), new OpenApiString("value"), true], + [new OpenApiString("value"), new OpenApiString("value2"), false], + [new OpenApiObject(), new OpenApiObject(), true], + [new OpenApiObject(), new OpenApiObject { ["key"] = new OpenApiString("value") }, false], + [new OpenApiObject { ["key"] = new OpenApiString("value") }, new OpenApiObject { ["key"] = new OpenApiString("value") }, true], + [new OpenApiObject { ["key"] = new OpenApiString("value") }, new OpenApiObject { ["key"] = new OpenApiString("value2") }, false], + [new OpenApiObject { ["key2"] = new OpenApiString("value") }, new OpenApiObject { ["key"] = new OpenApiString("value") }, false], + [new OpenApiDate(DateTime.Today), new OpenApiDate(DateTime.Today), true], + [new OpenApiDate(DateTime.Today), new OpenApiDate(DateTime.Today.AddDays(1)), false], + [new OpenApiPassword("password"), new OpenApiPassword("password"), true], + [new OpenApiPassword("password"), new OpenApiPassword("password2"), false], + [new OpenApiArray { new OpenApiString("value") }, new OpenApiArray { new OpenApiString("value") }, true], + [new OpenApiArray { new OpenApiString("value") }, new OpenApiArray { new OpenApiString("value2") }, false], + [new OpenApiArray { new OpenApiString("value2"), new OpenApiString("value") }, new OpenApiArray { new OpenApiString("value"), new OpenApiString("value2") }, false], + [new OpenApiArray { new OpenApiString("value"), new OpenApiString("value") }, new OpenApiArray { new OpenApiString("value"), new OpenApiString("value") }, true] + ]; + + [Theory] + [MemberData(nameof(Data))] + public void ProducesCorrectEqualityForOpenApiAny(IOpenApiAny any, IOpenApiAny anotherAny, bool isEqual) + => Assert.Equal(isEqual, OpenApiAnyComparer.Instance.Equals(any, anotherAny)); +} diff --git a/src/OpenApi/test/Comparers/OpenApiDiscriminatorComparerTests.cs b/src/OpenApi/test/Comparers/OpenApiDiscriminatorComparerTests.cs new file mode 100644 index 000000000000..bb411ab5198b --- /dev/null +++ b/src/OpenApi/test/Comparers/OpenApiDiscriminatorComparerTests.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Models; + +public class OpenApiDiscriminatorComparerTests +{ + public static object[][] Data => [ + [new OpenApiDiscriminator(), new OpenApiDiscriminator(), true], + [new OpenApiDiscriminator { PropertyName = "prop" }, new OpenApiDiscriminator(), false], + [new OpenApiDiscriminator { PropertyName = "prop" }, new OpenApiDiscriminator { PropertyName = "prop" }, true], + [new OpenApiDiscriminator { PropertyName = "prop2" }, new OpenApiDiscriminator { PropertyName = "prop" }, false], + [new OpenApiDiscriminator { PropertyName = "prop", Mapping = { ["key"] = "discriminatorValue" } }, new OpenApiDiscriminator { PropertyName = "prop", Mapping = { ["key"] = "discriminatorValue" } }, true], + [new OpenApiDiscriminator { PropertyName = "prop", Mapping = { ["key"] = "discriminatorValue" } }, new OpenApiDiscriminator { PropertyName = "prop2", Mapping = { ["key"] = "discriminatorValue" } }, false], + [new OpenApiDiscriminator { PropertyName = "prop", Mapping = { ["key"] = "discriminatorValue" } }, new OpenApiDiscriminator { PropertyName = "prop", Mapping = { ["key"] = "discriminatorValue2" } }, false] + ]; + + [Theory] + [MemberData(nameof(Data))] + public void ProducesCorrectEqualityForOpenApiDiscriminator(OpenApiDiscriminator discriminator, OpenApiDiscriminator anotherDiscriminator, bool isEqual) + => Assert.Equal(isEqual, OpenApiDiscriminatorComparer.Instance.Equals(discriminator, anotherDiscriminator)); +} diff --git a/src/OpenApi/test/Comparers/OpenApiExternalDocsComparerTests.cs b/src/OpenApi/test/Comparers/OpenApiExternalDocsComparerTests.cs new file mode 100644 index 000000000000..d2487a54ba4f --- /dev/null +++ b/src/OpenApi/test/Comparers/OpenApiExternalDocsComparerTests.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; + +public class OpenApiExternalDocsComparerTests +{ + public static object[][] Data => [ + [new OpenApiExternalDocs(), new OpenApiExternalDocs(), true], + [new OpenApiExternalDocs(), new OpenApiExternalDocs { Description = "description" }, false], + [new OpenApiExternalDocs { Description = "description" }, new OpenApiExternalDocs { Description = "description" }, true], + [new OpenApiExternalDocs { Description = "description" }, new OpenApiExternalDocs { Description = "description", Url = new Uri("http://localhost") }, false], + [new OpenApiExternalDocs { Description = "description", Url = new Uri("http://localhost") }, new OpenApiExternalDocs { Description = "description", Url = new Uri("http://localhost") }, true], + [new OpenApiExternalDocs { Description = "description", Url = new Uri("http://localhost") }, new OpenApiExternalDocs { Description = "description", Url = new Uri("http://localhost") }, true], + ]; + + [Theory] + [MemberData(nameof(Data))] + public void ProducesCorrectEqualityForOpenApiExternalDocs(OpenApiExternalDocs externalDocs, OpenApiExternalDocs anotherExternalDocs, bool isEqual) + => Assert.Equal(isEqual, OpenApiExternalDocsComparer.Instance.Equals(externalDocs, anotherExternalDocs)); +} diff --git a/src/OpenApi/test/Comparers/OpenApiReferenceComparerTests.cs b/src/OpenApi/test/Comparers/OpenApiReferenceComparerTests.cs new file mode 100644 index 000000000000..baa1526f50c9 --- /dev/null +++ b/src/OpenApi/test/Comparers/OpenApiReferenceComparerTests.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Models; + +public class OpenApiReferenceComparerTests +{ + public static object[][] Data => [ + [new OpenApiReference(), new OpenApiReference(), true], + [new OpenApiReference(), new OpenApiReference { Id = "id" }, false], + [new OpenApiReference { Id = "id" }, new OpenApiReference { Id = "id" }, true], + [new OpenApiReference { Id = "id" }, new OpenApiReference { Id = "id", Type = ReferenceType.Schema }, false], + [new OpenApiReference { Id = "id", Type = ReferenceType.Schema }, new OpenApiReference { Id = "id", Type = ReferenceType.Schema }, true], + [new OpenApiReference { Id = "id", Type = ReferenceType.Schema }, new OpenApiReference { Id = "id", Type = ReferenceType.Response }, false], + [new OpenApiReference { Id = "id", Type = ReferenceType.Response, ExternalResource = "http://localhost/pet.json" }, new OpenApiReference { Id = "id", Type = ReferenceType.Response, ExternalResource = "http://localhost/pet.json" }, true], + [new OpenApiReference { Id = "id", Type = ReferenceType.Response, ExternalResource = "http://localhost/pet.json" }, new OpenApiReference { Id = "id", Type = ReferenceType.Response, ExternalResource = "http://localhost/pet2.json" }, false], + [new OpenApiReference { Id = "id", Type = ReferenceType.Response, ExternalResource = "http://localhost/pet.json", HostDocument = new OpenApiDocument() }, new OpenApiReference { Id = "id", Type = ReferenceType.Response, ExternalResource = "http://localhost/pet.json", HostDocument = new OpenApiDocument() }, true], + [new OpenApiReference { Id = "id", Type = ReferenceType.Response, ExternalResource = "http://localhost/pet.json", HostDocument = new OpenApiDocument { Info = new() { Title = "Test" }} }, new OpenApiReference { Id = "id", Type = ReferenceType.Response, ExternalResource = "http://localhost/pet2.json", HostDocument = new OpenApiDocument() }, false] + ]; + + [Theory] + [MemberData(nameof(Data))] + public void ProducesCorrectEqualityForOpenApiReference(OpenApiReference reference, OpenApiReference anotherReference, bool isEqual) + => Assert.Equal(isEqual, OpenApiReferenceComparer.Instance.Equals(reference, anotherReference)); +} diff --git a/src/OpenApi/test/Comparers/OpenApiSchemaComparerTests.cs b/src/OpenApi/test/Comparers/OpenApiSchemaComparerTests.cs new file mode 100644 index 000000000000..3678c4c58734 --- /dev/null +++ b/src/OpenApi/test/Comparers/OpenApiSchemaComparerTests.cs @@ -0,0 +1,307 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Interfaces; +using Microsoft.OpenApi.Models; + +public class OpenApiSchemaComparerTests +{ + public static object[][] SinglePropertyData => [ + [new OpenApiSchema { Title = "Title" }, new OpenApiSchema { Title = "Title" }, true], + [new OpenApiSchema { Title = "Title" }, new OpenApiSchema { Title = "Another Title" }, false], + [new OpenApiSchema { Type = "string" }, new OpenApiSchema { Type = "string" }, true], + [new OpenApiSchema { Type = "string" }, new OpenApiSchema { Type = "integer" }, false], + [new OpenApiSchema { Format = "int32" }, new OpenApiSchema { Format = "int32" }, true], + [new OpenApiSchema { Format = "int32" }, new OpenApiSchema { Format = "int64" }, false], + [new OpenApiSchema { Maximum = 10 }, new OpenApiSchema { Maximum = 10 }, true], + [new OpenApiSchema { Maximum = 10 }, new OpenApiSchema { Maximum = 20 }, false], + [new OpenApiSchema { Minimum = 10 }, new OpenApiSchema { Minimum = 10 }, true], + [new OpenApiSchema { Minimum = 10 }, new OpenApiSchema { Minimum = 20 }, false], + [new OpenApiSchema { ExclusiveMaximum = true }, new OpenApiSchema { ExclusiveMaximum = true }, true], + [new OpenApiSchema { ExclusiveMaximum = true }, new OpenApiSchema { ExclusiveMaximum = false }, false], + [new OpenApiSchema { ExclusiveMinimum = true }, new OpenApiSchema { ExclusiveMinimum = true }, true], + [new OpenApiSchema { ExclusiveMinimum = true }, new OpenApiSchema { ExclusiveMinimum = false }, false], + [new OpenApiSchema { MaxLength = 10 }, new OpenApiSchema { MaxLength = 10 }, true], + [new OpenApiSchema { MaxLength = 10 }, new OpenApiSchema { MaxLength = 20 }, false], + [new OpenApiSchema { MinLength = 10 }, new OpenApiSchema { MinLength = 10 }, true], + [new OpenApiSchema { MinLength = 10 }, new OpenApiSchema { MinLength = 20 }, false], + [new OpenApiSchema { Pattern = "pattern" }, new OpenApiSchema { Pattern = "pattern" }, true], + [new OpenApiSchema { Pattern = "pattern" }, new OpenApiSchema { Pattern = "another pattern" }, false], + [new OpenApiSchema { MaxItems = 10 }, new OpenApiSchema { MaxItems = 10 }, true], + [new OpenApiSchema { MaxItems = 10 }, new OpenApiSchema { MaxItems = 20 }, false], + [new OpenApiSchema { MinItems = 10 }, new OpenApiSchema { MinItems = 10 }, true], + [new OpenApiSchema { MinItems = 10 }, new OpenApiSchema { MinItems = 20 }, false], + [new OpenApiSchema { UniqueItems = true }, new OpenApiSchema { UniqueItems = true }, true], + [new OpenApiSchema { UniqueItems = true }, new OpenApiSchema { UniqueItems = false }, false], + [new OpenApiSchema { MaxProperties = 10 }, new OpenApiSchema { MaxProperties = 10 }, true], + [new OpenApiSchema { MaxProperties = 10 }, new OpenApiSchema { MaxProperties = 20 }, false], + [new OpenApiSchema { MinProperties = 10 }, new OpenApiSchema { MinProperties = 10 }, true], + [new OpenApiSchema { MinProperties = 10 }, new OpenApiSchema { MinProperties = 20 }, false], + [new OpenApiSchema { Required = new HashSet() { "required" } }, new OpenApiSchema { Required = new HashSet { "required" } }, true], + [new OpenApiSchema { Required = new HashSet() { "name", "age" } }, new OpenApiSchema { Required = new HashSet { "age", "name" } }, true], + [new OpenApiSchema { Required = new HashSet() { "required" } }, new OpenApiSchema { Required = new HashSet { "another required" } }, false], + [new OpenApiSchema { Enum = [new OpenApiString("value")] }, new OpenApiSchema { Enum = [new OpenApiString("value")] }, true], + [new OpenApiSchema { Enum = [new OpenApiString("value")] }, new OpenApiSchema { Enum = [new OpenApiString("value2" )] }, false], + [new OpenApiSchema { Enum = [new OpenApiString("value"), new OpenApiString("value2")] }, new OpenApiSchema { Enum = [new OpenApiString("value2" ), new OpenApiString("value" )] }, false], + [new OpenApiSchema { Items = new OpenApiSchema { Type = "string" } }, new OpenApiSchema { Items = new OpenApiSchema { Type = "string" } }, true], + [new OpenApiSchema { Items = new OpenApiSchema { Type = "string" } }, new OpenApiSchema { Items = new OpenApiSchema { Type = "integer" } }, false], + [new OpenApiSchema { Properties = new Dictionary { ["name"] = new OpenApiSchema { Type = "string" } } }, new OpenApiSchema { Properties = new Dictionary { ["name"] = new OpenApiSchema { Type = "string" } } }, true], + [new OpenApiSchema { Properties = new Dictionary { ["name"] = new OpenApiSchema { Type = "string" } } }, new OpenApiSchema { Properties = new Dictionary { ["name"] = new OpenApiSchema { Type = "integer" } } }, false], + [new OpenApiSchema { AdditionalProperties = new OpenApiSchema { Type = "string" } }, new OpenApiSchema { AdditionalProperties = new OpenApiSchema { Type = "string" } }, true], + [new OpenApiSchema { AdditionalProperties = new OpenApiSchema { Type = "string" } }, new OpenApiSchema { AdditionalProperties = new OpenApiSchema { Type = "integer" } }, false], + [new OpenApiSchema { Description = "Description" }, new OpenApiSchema { Description = "Description" }, true], + [new OpenApiSchema { Description = "Description" }, new OpenApiSchema { Description = "Another Description" }, false], + [new OpenApiSchema { Deprecated = true }, new OpenApiSchema { Deprecated = true }, true], + [new OpenApiSchema { Deprecated = true }, new OpenApiSchema { Deprecated = false }, false], + [new OpenApiSchema { ExternalDocs = new OpenApiExternalDocs { Description = "Description" } }, new OpenApiSchema { ExternalDocs = new OpenApiExternalDocs { Description = "Description" } }, true], + [new OpenApiSchema { ExternalDocs = new OpenApiExternalDocs { Description = "Description" } }, new OpenApiSchema { ExternalDocs = new OpenApiExternalDocs { Description = "Another Description" } }, false], + [new OpenApiSchema { UnresolvedReference = true }, new OpenApiSchema { UnresolvedReference = true }, true], + [new OpenApiSchema { UnresolvedReference = true }, new OpenApiSchema { UnresolvedReference = false }, false], + [new OpenApiSchema { Reference = new OpenApiReference { Id = "Id", Type = ReferenceType.Schema } }, new OpenApiSchema { Reference = new OpenApiReference { Id = "Id", Type = ReferenceType.Schema } }, true], + [new OpenApiSchema { Reference = new OpenApiReference { Id = "Id", Type = ReferenceType.Schema } }, new OpenApiSchema { Reference = new OpenApiReference { Id = "Another Id", Type = ReferenceType.Schema } }, false], + [new OpenApiSchema { Extensions = new Dictionary { ["key"] = new OpenApiString("value") } }, new OpenApiSchema { Extensions = new Dictionary { ["key"] = new OpenApiString("value") } }, true], + [new OpenApiSchema { Extensions = new Dictionary { ["key"] = new OpenApiString("value") } }, new OpenApiSchema { Extensions = new Dictionary { ["key"] = new OpenApiString("another value") } }, false], + [new OpenApiSchema { Extensions = new Dictionary { ["key"] = new OpenApiString("value") } }, new OpenApiSchema { Extensions = new Dictionary { ["key2"] = new OpenApiString("value") } }, false], + [new OpenApiSchema { Xml = new OpenApiXml { Name = "Name" } }, new OpenApiSchema { Xml = new OpenApiXml { Name = "Name" } }, true], + [new OpenApiSchema { Xml = new OpenApiXml { Name = "Name" } }, new OpenApiSchema { Xml = new OpenApiXml { Name = "Another Name" } }, false], + [new OpenApiSchema { Nullable = true }, new OpenApiSchema { Nullable = true }, true], + [new OpenApiSchema { Nullable = true }, new OpenApiSchema { Nullable = false }, false], + [new OpenApiSchema { ReadOnly = true }, new OpenApiSchema { ReadOnly = true }, true], + [new OpenApiSchema { ReadOnly = true }, new OpenApiSchema { ReadOnly = false }, false], + [new OpenApiSchema { WriteOnly = true }, new OpenApiSchema { WriteOnly = true }, true], + [new OpenApiSchema { WriteOnly = true }, new OpenApiSchema { WriteOnly = false }, false], + [new OpenApiSchema { Discriminator = new OpenApiDiscriminator { PropertyName = "PropertyName" } }, new OpenApiSchema { Discriminator = new OpenApiDiscriminator { PropertyName = "PropertyName" } }, true], + [new OpenApiSchema { Discriminator = new OpenApiDiscriminator { PropertyName = "PropertyName" } }, new OpenApiSchema { Discriminator = new OpenApiDiscriminator { PropertyName = "AnotherPropertyName" } }, false], + [new OpenApiSchema { Example = new OpenApiString("example") }, new OpenApiSchema { Example = new OpenApiString("example") }, true], + [new OpenApiSchema { Example = new OpenApiString("example") }, new OpenApiSchema { Example = new OpenApiString("another example") }, false], + [new OpenApiSchema { Example = new OpenApiInteger(2) }, new OpenApiSchema { Example = new OpenApiString("another example") }, false], + [new OpenApiSchema { AdditionalPropertiesAllowed = true }, new OpenApiSchema { AdditionalPropertiesAllowed = true }, true], + [new OpenApiSchema { AdditionalPropertiesAllowed = true }, new OpenApiSchema { AdditionalPropertiesAllowed = false }, false], + [new OpenApiSchema { Not = new OpenApiSchema { Type = "string" } }, new OpenApiSchema { Not = new OpenApiSchema { Type = "string" } }, true], + [new OpenApiSchema { Not = new OpenApiSchema { Type = "string" } }, new OpenApiSchema { Not = new OpenApiSchema { Type = "integer" } }, false], + [new OpenApiSchema { AnyOf = [new OpenApiSchema { Type = "string" }] }, new OpenApiSchema { AnyOf = [new OpenApiSchema { Type = "string" }] }, true], + [new OpenApiSchema { AnyOf = [new OpenApiSchema { Type = "string" }] }, new OpenApiSchema { AnyOf = [new OpenApiSchema { Type = "integer" }] }, false], + [new OpenApiSchema { AllOf = [new OpenApiSchema { Type = "string" }] }, new OpenApiSchema { AllOf = [new OpenApiSchema { Type = "string" }] }, true], + [new OpenApiSchema { AllOf = [new OpenApiSchema { Type = "string" }] }, new OpenApiSchema { AllOf = [new OpenApiSchema { Type = "integer" }] }, false], + [new OpenApiSchema { OneOf = [new OpenApiSchema { Type = "string" }] }, new OpenApiSchema { OneOf = [new OpenApiSchema { Type = "string" }] }, true], + [new OpenApiSchema { OneOf = [new OpenApiSchema { Type = "string" }] }, new OpenApiSchema { OneOf = [new OpenApiSchema { Type = "integer" }] }, false], + [new OpenApiSchema { MultipleOf = 10 }, new OpenApiSchema { MultipleOf = 10 }, true], + [new OpenApiSchema { MultipleOf = 10 }, new OpenApiSchema { MultipleOf = 20 }, false], + [new OpenApiSchema { Default = new OpenApiString("default") }, new OpenApiSchema { Default = new OpenApiString("default") }, true], + [new OpenApiSchema { Default = new OpenApiString("default") }, new OpenApiSchema { Default = new OpenApiString("another default") }, false], + ]; + + [Theory] + [MemberData(nameof(SinglePropertyData))] + public void ProducesCorrectEqualityForOpenApiSchema(OpenApiSchema schema, OpenApiSchema anotherSchema, bool isEqual) + => Assert.Equal(isEqual, OpenApiSchemaComparer.Instance.Equals(schema, anotherSchema)); + + [Fact] + public void ValidatePropertiesOnOpenApiSchema() + { + var propertyNames = typeof(OpenApiSchema).GetProperties().Select(property => property.Name).ToList(); + var originalSchema = new OpenApiSchema + { + AdditionalProperties = new OpenApiSchema(), + AdditionalPropertiesAllowed = true, + AllOf = [new OpenApiSchema()], + AnyOf = [new OpenApiSchema()], + Deprecated = true, + Default = new OpenApiString("default"), + Description = "description", + Discriminator = new OpenApiDiscriminator(), + Example = new OpenApiString("example"), + ExclusiveMaximum = true, + ExclusiveMinimum = true, + Extensions = new Dictionary + { + ["key"] = new OpenApiString("value") + }, + ExternalDocs = new OpenApiExternalDocs(), + Enum = [new OpenApiString("test")], + Format = "object", + Items = new OpenApiSchema(), + Maximum = 10, + MaxItems = 10, + MaxLength = 10, + MaxProperties = 10, + Minimum = 10, + MinItems = 10, + MinLength = 10, + MinProperties = 10, + MultipleOf = 10, + OneOf = [new OpenApiSchema()], + Not = new OpenApiSchema(), + Nullable = false, + Pattern = "pattern", + Properties = new Dictionary { ["name"] = new OpenApiSchema() }, + ReadOnly = true, + Required = new HashSet { "required" }, + Reference = new OpenApiReference { Id = "Id", Type = ReferenceType.Schema }, + UniqueItems = false, + UnresolvedReference = true, + WriteOnly = true, + Xml = new OpenApiXml { Name = "Name" }, + }; + + OpenApiSchema modifiedSchema = new(originalSchema) { AdditionalProperties = new OpenApiSchema { Type = "string" } }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.AdditionalProperties))); + + modifiedSchema = new(originalSchema) { AdditionalPropertiesAllowed = false }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.AdditionalPropertiesAllowed))); + + modifiedSchema = new(originalSchema) { AllOf = [new OpenApiSchema { Type = "string" }] }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.AllOf))); + + modifiedSchema = new(originalSchema) { AnyOf = [new OpenApiSchema { Type = "string" }] }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.AnyOf))); + + modifiedSchema = new(originalSchema) { Deprecated = false }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.Deprecated))); + + modifiedSchema = new(originalSchema) { Default = new OpenApiString("another default") }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.Default))); + + modifiedSchema = new(originalSchema) { Description = "another description" }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.Description))); + + modifiedSchema = new(originalSchema) { Discriminator = new OpenApiDiscriminator { PropertyName = "PropertyName" } }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.Discriminator))); + + modifiedSchema = new(originalSchema) { Example = new OpenApiString("another example") }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.Example))); + + modifiedSchema = new(originalSchema) { ExclusiveMaximum = false }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.ExclusiveMaximum))); + + modifiedSchema = new(originalSchema) { ExclusiveMinimum = false }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.ExclusiveMinimum))); + + modifiedSchema = new(originalSchema) { Extensions = new Dictionary { ["key"] = new OpenApiString("another value") } }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.Extensions))); + + modifiedSchema = new(originalSchema) { ExternalDocs = new OpenApiExternalDocs { Description = "another description" } }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.ExternalDocs))); + + modifiedSchema = new(originalSchema) { Enum = [new OpenApiString("another test")] }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.Enum))); + + modifiedSchema = new(originalSchema) { Format = "string" }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.Format))); + + modifiedSchema = new(originalSchema) { Items = new OpenApiSchema { Type = "string" } }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.Items))); + + modifiedSchema = new(originalSchema) { Maximum = 20 }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.Maximum))); + + modifiedSchema = new(originalSchema) { MaxItems = 20 }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.MaxItems))); + + modifiedSchema = new(originalSchema) { MaxLength = 20 }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.MaxLength))); + + modifiedSchema = new(originalSchema) { MaxProperties = 20 }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.MaxProperties))); + + modifiedSchema = new(originalSchema) { Minimum = 20 }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.Minimum))); + + modifiedSchema = new(originalSchema) { MinItems = 20 }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.MinItems))); + + modifiedSchema = new(originalSchema) { MinLength = 20 }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.MinLength))); + + modifiedSchema = new(originalSchema) { MinProperties = 20 }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.MinProperties))); + + modifiedSchema = new(originalSchema) { MultipleOf = 20 }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.MultipleOf))); + + modifiedSchema = new(originalSchema) { OneOf = [new OpenApiSchema { Type = "string" }] }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.OneOf))); + + modifiedSchema = new(originalSchema) { Not = new OpenApiSchema { Type = "string" } }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.Not))); + + modifiedSchema = new(originalSchema) { Nullable = true }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.Nullable))); + + modifiedSchema = new(originalSchema) { Pattern = "another pattern" }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.Pattern))); + + modifiedSchema = new(originalSchema) { Properties = new Dictionary { ["name"] = new OpenApiSchema { Type = "integer" } } }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.Properties))); + + modifiedSchema = new(originalSchema) { ReadOnly = false }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.ReadOnly))); + + modifiedSchema = new(originalSchema) { Required = new HashSet { "another required" } }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.Required))); + + modifiedSchema = new(originalSchema) { Reference = new OpenApiReference { Id = "Another Id", Type = ReferenceType.Schema } }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.Reference))); + + modifiedSchema = new(originalSchema) { Title = "Another Title" }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.Title))); + + modifiedSchema = new(originalSchema) { Type = "integer" }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.Type))); + + modifiedSchema = new(originalSchema) { UniqueItems = true }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.UniqueItems))); + + modifiedSchema = new(originalSchema) { UnresolvedReference = false }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.UnresolvedReference))); + + modifiedSchema = new(originalSchema) { WriteOnly = false }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.WriteOnly))); + + modifiedSchema = new(originalSchema) { Xml = new OpenApiXml { Name = "Another Name" } }; + Assert.False(OpenApiSchemaComparer.Instance.Equals(originalSchema, modifiedSchema)); + Assert.True(propertyNames.Remove(nameof(OpenApiSchema.Xml))); + + Assert.Empty(propertyNames); + } +} diff --git a/src/OpenApi/test/Comparers/OpenApiXmlComparerTests.cs b/src/OpenApi/test/Comparers/OpenApiXmlComparerTests.cs new file mode 100644 index 000000000000..49b116efb0f0 --- /dev/null +++ b/src/OpenApi/test/Comparers/OpenApiXmlComparerTests.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Models; + +public class OpenApiXmlComparerTests +{ + public static object[][] Data => [ + [new OpenApiXml(), new OpenApiXml(), true], + [new OpenApiXml(), new OpenApiXml { Name = "name" }, false], + [new OpenApiXml { Name = "name" }, new OpenApiXml { Name = "name" }, true], + [new OpenApiXml { Name = "name" }, new OpenApiXml { Name = "name", Namespace = new Uri("http://localhost.com/namespace") }, false], + [new OpenApiXml { Name = "name", Namespace = new Uri("http://localhost.com/namespace") }, new OpenApiXml { Name = "name", Namespace = new Uri("http://localhost.com/namespace") }, true], + [new OpenApiXml { Name = "name", Namespace = new Uri("http://localhost.com/namespace") }, new OpenApiXml { Name = "name", Namespace = new Uri("http://localhost.com/namespace2") }, false], + [new OpenApiXml { Name = "name", Namespace = new Uri("http://localhost.com/namespace"), Prefix = "prefix" }, new OpenApiXml { Name = "name", Namespace = new Uri("http://localhost.com/namespace"), Prefix = "prefix" }, true], + [new OpenApiXml { Name = "name", Namespace = new Uri("http://localhost.com/namespace"), Prefix = "prefix" }, new OpenApiXml { Name = "name", Namespace = new Uri("http://localhost.com/namespace"), Prefix = "prefix2" }, false] + ]; + + [Theory] + [MemberData(nameof(Data))] + public void ProducesCorrectEqualityForOpenApiXml(OpenApiXml xml, OpenApiXml anotherXml, bool isEqual) + => Assert.Equal(isEqual, OpenApiXmlComparer.Instance.Equals(xml, anotherXml)); +} diff --git a/src/OpenApi/test/Extensions/JsonTypeInfoExtensionsTests.cs b/src/OpenApi/test/Extensions/JsonTypeInfoExtensionsTests.cs new file mode 100644 index 000000000000..1095798e7c97 --- /dev/null +++ b/src/OpenApi/test/Extensions/JsonTypeInfoExtensionsTests.cs @@ -0,0 +1,70 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO.Pipelines; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OpenApi; + +public class JsonTypeInfoExtensionsTests +{ + private delegate void TestDelegate(int x, int y); + + private class Container + { + internal delegate void ContainedTestDelegate(int x, int y); + } + + /// + /// This data is used to test the method + /// which is used to generate reference IDs for OpenAPI schemas in the OpenAPI document. + /// + /// Some things of note: + /// - For generic types, we generate the reference ID by appending the type arguments to the type name. + /// Our implementation currently supports versions of OpenAPI up to v3.0 which do not include support for + /// generic types in schemas. This means that generic types must be resolved to their concrete types before + /// being encoded in teh OpenAPI document. + /// - Array-like types (List, IEnumerable, etc.) are represented as "ArrayOf" followed by the type name of the + /// element type. + /// - Dictionary-list types are represented as "DictionaryOf" followed by the key type and the value type. + /// - Supported primitive types are mapped to their corresponding names (string, char, Uri, etc.). + /// + /// + public static IEnumerable GetSchemaReferenceId_Data => + [ + [typeof(Todo), "Todo"], + [typeof(IEnumerable), "ArrayOfTodo"], + [typeof(List), "ArrayOfTodo"], + [typeof(TodoWithDueDate), "TodoWithDueDate"], + [typeof(IEnumerable), "ArrayOfTodoWithDueDate"], + [(new { Id = 1 }).GetType(), "AnonymousTypeOfint"], + [(new { Id = 1, Name = "Todo" }).GetType(), "AnonymousTypeOfintAndstring"], + [typeof(IFormFile), "IFormFile"], + [typeof(IFormFileCollection), "IFormFileCollection"], + [typeof(Stream), "Stream"], + [typeof(PipeReader), "PipeReader"], + [typeof(Results, Ok>), "ResultsOfOkOfTodoWithDueDateAndOkOfTodo"], + [typeof(Ok), "OkOfTodo"], + [typeof(NotFound), "NotFoundOfTodoWithDueDate"], + [typeof(TestDelegate), "TestDelegate"], + [typeof(Container.ContainedTestDelegate), "ContainedTestDelegate"], + [typeof(List), "ArrayOfint"], + [typeof(List>), "ArrayOfArrayOfint"], + [typeof(int[]), "ArrayOfint"], + [typeof(ValidationProblemDetails), "ValidationProblemDetails"], + [typeof(ProblemDetails), "ProblemDetails"], + [typeof(Dictionary), "DictionaryOfstringAndArrayOfstring"], + [typeof(Dictionary>), "DictionaryOfstringAndArrayOfArrayOfstring"], + [typeof(Dictionary>), "DictionaryOfstringAndArrayOfArrayOfstring"], + ]; + + [Theory] + [MemberData(nameof(GetSchemaReferenceId_Data))] + public void GetSchemaReferenceId_Works(Type type, string referenceId) + { + var jsonTypeInfo = JsonSerializerOptions.Default.GetTypeInfo(type); + Assert.Equal(referenceId, jsonTypeInfo.GetSchemaReferenceId()); + } +} diff --git a/src/OpenApi/test/Integration/OpenApiDocumentIntegrationTests.cs b/src/OpenApi/test/Integration/OpenApiDocumentIntegrationTests.cs index 5ae4b64d9215..def8474834f9 100644 --- a/src/OpenApi/test/Integration/OpenApiDocumentIntegrationTests.cs +++ b/src/OpenApi/test/Integration/OpenApiDocumentIntegrationTests.cs @@ -14,8 +14,10 @@ public sealed class OpenApiDocumentIntegrationTests(SampleAppFixture fixture) : [Theory] [InlineData("v1")] [InlineData("v2")] + [InlineData("controllers")] [InlineData("responses")] [InlineData("forms")] + [InlineData("schemas-by-ref")] public async Task VerifyOpenApiDocument(string documentName) { var documentService = fixture.Services.GetRequiredKeyedService(documentName); diff --git a/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt b/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt new file mode 100644 index 000000000000..1381536d4b71 --- /dev/null +++ b/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt @@ -0,0 +1,111 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Sample | controllers", + "version": "1.0.0" + }, + "paths": { + "/getbyidandname/{id}/{name}": { + "get": { + "tags": [ + "Test" + ], + "parameters": [ + { + "name": "Id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "Name", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/string2" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/string" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/string" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/string" + } + } + } + } + } + } + }, + "/forms": { + "post": { + "tags": [ + "Test" + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "Title": { + "$ref": "#/components/schemas/string2" + }, + "Description": { + "$ref": "#/components/schemas/string2" + }, + "IsCompleted": { + "type": "boolean" + } + } + }, + "encoding": { + "application/x-www-form-urlencoded": { + "style": "form", + "explode": true + } + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + } + }, + "components": { + "schemas": { + "string": { + "type": "string" + }, + "string2": { + "minLength": 5, + "type": "string" + } + } + }, + "tags": [ + { + "name": "Test" + } + ] +} \ No newline at end of file diff --git a/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt b/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt index c21b849f0204..a82a5f4d0237 100644 --- a/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt +++ b/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=forms.verified.txt @@ -17,8 +17,7 @@ "type": "object", "properties": { "resume": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/IFormFile" } } }, @@ -51,11 +50,7 @@ "type": "object", "properties": { "files": { - "type": "array", - "items": { - "type": "string", - "format": "binary" - } + "$ref": "#/components/schemas/IFormFileCollection" } } }, @@ -91,8 +86,7 @@ "type": "object", "properties": { "resume": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/IFormFile" } } }, @@ -100,11 +94,7 @@ "type": "object", "properties": { "files": { - "type": "array", - "items": { - "type": "string", - "format": "binary" - } + "$ref": "#/components/schemas/IFormFileCollection" } } } @@ -136,29 +126,7 @@ "content": { "multipart/form-data": { "schema": { - "required": [ - "id", - "title", - "completed", - "createdAt" - ], - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int32" - }, - "title": { - "type": "string" - }, - "completed": { - "type": "boolean" - }, - "createdAt": { - "type": "string", - "format": "date-time" - } - } + "$ref": "#/components/schemas/Todo" }, "encoding": { "multipart/form-data": { @@ -169,29 +137,7 @@ }, "application/x-www-form-urlencoded": { "schema": { - "required": [ - "id", - "title", - "completed", - "createdAt" - ], - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int32" - }, - "title": { - "type": "string" - }, - "completed": { - "type": "boolean" - }, - "createdAt": { - "type": "string", - "format": "date-time" - } - } + "$ref": "#/components/schemas/Todo" }, "encoding": { "application/x-www-form-urlencoded": { @@ -222,36 +168,13 @@ "type": "object", "allOf": [ { - "required": [ - "id", - "title", - "completed", - "createdAt" - ], - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int32" - }, - "title": { - "type": "string" - }, - "completed": { - "type": "boolean" - }, - "createdAt": { - "type": "string", - "format": "date-time" - } - } + "$ref": "#/components/schemas/Todo" }, { "type": "object", "properties": { "file": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/IFormFile" } } } @@ -273,92 +196,54 @@ } } } - }, - "/getbyidandname/{id}/{name}": { - "get": { - "tags": [ - "Test" - ], - "parameters": [ - { - "name": "Id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "Name", - "in": "path", - "required": true, - "schema": { - "minLength": 5, - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - }, - "application/json": { - "schema": { - "type": "string" - } - }, - "text/json": { - "schema": { - "type": "string" - } - } - } - } + } + }, + "components": { + "schemas": { + "boolean": { + "type": "boolean" + }, + "DateTime": { + "type": "string", + "format": "date-time" + }, + "IFormFile": { + "type": "string", + "format": "binary" + }, + "IFormFileCollection": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IFormFile" } - } - }, - "/forms": { - "post": { - "tags": [ - "Test" + }, + "int": { + "type": "integer", + "format": "int32" + }, + "string": { + "type": "string" + }, + "Todo": { + "required": [ + "id", + "title", + "completed", + "createdAt" ], - "requestBody": { - "content": { - "application/x-www-form-urlencoded": { - "schema": { - "type": "object", - "properties": { - "Title": { - "minLength": 5, - "type": "string" - }, - "Description": { - "minLength": 5, - "type": "string" - }, - "IsCompleted": { - "type": "boolean" - } - } - }, - "encoding": { - "application/x-www-form-urlencoded": { - "style": "form", - "explode": true - } - } - } - } - }, - "responses": { - "200": { - "description": "OK" + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/int" + }, + "title": { + "$ref": "#/components/schemas/string" + }, + "completed": { + "$ref": "#/components/schemas/boolean" + }, + "createdAt": { + "$ref": "#/components/schemas/DateTime" } } } @@ -367,9 +252,6 @@ "tags": [ { "name": "Sample" - }, - { - "name": "Test" } ] } \ No newline at end of file diff --git a/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt b/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt index d09854500b2c..b829606d7471 100644 --- a/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt +++ b/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=responses.verified.txt @@ -16,56 +16,12 @@ "content": { "application/json": { "schema": { - "required": [ - "id", - "title", - "completed", - "createdAt" - ], - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int32" - }, - "title": { - "type": "string" - }, - "completed": { - "type": "boolean" - }, - "createdAt": { - "type": "string", - "format": "date-time" - } - } + "$ref": "#/components/schemas/Todo" } }, "text/xml": { "schema": { - "required": [ - "id", - "title", - "completed", - "createdAt" - ], - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int32" - }, - "title": { - "type": "string" - }, - "completed": { - "type": "boolean" - }, - "createdAt": { - "type": "string", - "format": "date-time" - } - } + "$ref": "#/components/schemas/Todo" } } } @@ -84,29 +40,7 @@ "content": { "text/xml": { "schema": { - "required": [ - "id", - "title", - "completed", - "createdAt" - ], - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int32" - }, - "title": { - "type": "string" - }, - "completed": { - "type": "boolean" - }, - "createdAt": { - "type": "string", - "format": "date-time" - } - } + "$ref": "#/components/schemas/Todo" } } } @@ -174,92 +108,44 @@ } } } - }, - "/getbyidandname/{id}/{name}": { - "get": { - "tags": [ - "Test" + } + }, + "components": { + "schemas": { + "boolean": { + "type": "boolean" + }, + "DateTime": { + "type": "string", + "format": "date-time" + }, + "int": { + "type": "integer", + "format": "int32" + }, + "string": { + "type": "string" + }, + "Todo": { + "required": [ + "id", + "title", + "completed", + "createdAt" ], - "parameters": [ - { - "name": "Id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/int" }, - { - "name": "Name", - "in": "path", - "required": true, - "schema": { - "minLength": 5, - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - }, - "application/json": { - "schema": { - "type": "string" - } - }, - "text/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/forms": { - "post": { - "tags": [ - "Test" - ], - "requestBody": { - "content": { - "application/x-www-form-urlencoded": { - "schema": { - "type": "object", - "properties": { - "Title": { - "minLength": 5, - "type": "string" - }, - "Description": { - "minLength": 5, - "type": "string" - }, - "IsCompleted": { - "type": "boolean" - } - } - }, - "encoding": { - "application/x-www-form-urlencoded": { - "style": "form", - "explode": true - } - } - } - } - }, - "responses": { - "200": { - "description": "OK" + "title": { + "$ref": "#/components/schemas/string" + }, + "completed": { + "$ref": "#/components/schemas/boolean" + }, + "createdAt": { + "$ref": "#/components/schemas/DateTime" } } } @@ -268,9 +154,6 @@ "tags": [ { "name": "Sample" - }, - { - "name": "Test" } ] } \ No newline at end of file diff --git a/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt b/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt new file mode 100644 index 000000000000..f85c1f035a70 --- /dev/null +++ b/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=schemas-by-ref.verified.txt @@ -0,0 +1,348 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Sample | schemas-by-ref", + "version": "1.0.0" + }, + "paths": { + "/schemas-by-ref/typed-results": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Triangle" + } + } + } + } + } + } + }, + "/schemas-by-ref/multiple-results": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Triangle" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/string" + } + } + } + } + } + } + }, + "/schemas-by-ref/iresult-no-produces": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/iresult-with-produces": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Triangle" + } + } + } + } + } + } + }, + "/schemas-by-ref/primitives": { + "get": { + "tags": [ + "Sample" + ], + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "description": "The ID associated with the Todo item.", + "format": "int32" + } + }, + { + "name": "size", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "description": "The number of Todos to fetch", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/schemas-by-ref/product": { + "get": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Product" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Product" + } + } + } + } + } + } + }, + "/schemas-by-ref/account": { + "get": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Account" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Account" + } + } + } + } + } + } + }, + "/schemas-by-ref/array-of-ints": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArrayOfint" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/int" + } + } + } + } + } + } + }, + "/schemas-by-ref/list-of-ints": { + "post": { + "tags": [ + "Sample" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArrayOfint" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/int" + } + } + } + } + } + } + }, + "/schemas-by-ref/ienumerable-of-ints": { + "post": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/int" + } + } + } + } + } + } + }, + "/schemas-by-ref/dictionary-of-ints": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DictionaryOfstringAndint" + } + } + } + } + } + } + }, + "/schemas-by-ref/frozen-dictionary-of-ints": { + "get": { + "tags": [ + "Sample" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DictionaryOfstringAndint" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Account": { + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/int" + }, + "name": { + "$ref": "#/components/schemas/string" + } + } + }, + "ArrayOfint": { + "type": "array", + "items": { + "$ref": "#/components/schemas/int" + } + }, + "DictionaryOfstringAndint": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/int" + } + }, + "int": { + "type": "integer", + "format": "int32" + }, + "Product": { + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/int" + }, + "name": { + "$ref": "#/components/schemas/string" + } + } + }, + "string": { + "type": "string" + }, + "Triangle": { + "type": "object" + } + } + }, + "tags": [ + { + "name": "Sample" + } + ] +} \ No newline at end of file diff --git a/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt b/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt index 4004980ec53d..17594e92aecc 100644 --- a/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt +++ b/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v1.verified.txt @@ -16,11 +16,7 @@ "in": "query", "required": true, "schema": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } + "$ref": "#/components/schemas/ArrayOfGuid" } }, { @@ -38,11 +34,7 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } + "$ref": "#/components/schemas/ArrayOfGuid" } } } @@ -79,18 +71,16 @@ "type": "object", "properties": { "id": { - "type": "integer", - "format": "int32" + "$ref": "#/components/schemas/int" }, "title": { - "type": "string" + "$ref": "#/components/schemas/string" }, "completed": { - "type": "boolean" + "$ref": "#/components/schemas/boolean" }, "createdAt": { - "type": "string", - "format": "date-time" + "$ref": "#/components/schemas/DateTime" } } } @@ -117,8 +107,7 @@ "in": "path", "required": true, "schema": { - "type": "integer", - "format": "int32" + "$ref": "#/components/schemas/int" } }, { @@ -146,22 +135,19 @@ "type": "object", "properties": { "dueDate": { - "type": "string", - "format": "date-time" + "$ref": "#/components/schemas/DateTime" }, "id": { - "type": "integer", - "format": "int32" + "$ref": "#/components/schemas/int" }, "title": { - "type": "string" + "$ref": "#/components/schemas/string" }, "completed": { - "type": "boolean" + "$ref": "#/components/schemas/boolean" }, "createdAt": { - "type": "string", - "format": "date-time" + "$ref": "#/components/schemas/DateTime" } } } @@ -170,116 +156,35 @@ } } } - }, - "/getbyidandname/{id}/{name}": { - "get": { - "tags": [ - "Test" - ], - "parameters": [ - { - "name": "Id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "Name", - "in": "path", - "required": true, - "schema": { - "minLength": 5, - "type": "string" - } - }, - { - "name": "X-Version", - "in": "header", - "schema": { - "type": "string", - "default": "1.0" - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - }, - "application/json": { - "schema": { - "type": "string" - } - }, - "text/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/forms": { - "post": { - "tags": [ - "Test" - ], - "parameters": [ - { - "name": "X-Version", - "in": "header", - "schema": { - "type": "string", - "default": "1.0" - } - } - ], - "requestBody": { - "content": { - "application/x-www-form-urlencoded": { - "schema": { - "type": "object", - "properties": { - "Title": { - "minLength": 5, - "type": "string" - }, - "Description": { - "minLength": 5, - "type": "string" - }, - "IsCompleted": { - "type": "boolean" - } - } - }, - "encoding": { - "application/x-www-form-urlencoded": { - "style": "form", - "explode": true - } - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } } }, "components": { + "schemas": { + "ArrayOfGuid": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Guid" + } + }, + "boolean": { + "type": "boolean" + }, + "DateTime": { + "type": "string", + "format": "date-time" + }, + "Guid": { + "type": "string", + "format": "uuid" + }, + "int": { + "type": "integer", + "format": "int32" + }, + "string": { + "type": "string" + } + }, "securitySchemes": { "Bearer": { "type": "http", @@ -291,9 +196,6 @@ "tags": [ { "name": "Sample" - }, - { - "name": "Test" } ] } \ No newline at end of file diff --git a/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v2.verified.txt b/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v2.verified.txt index 3ae26ab06892..62cb111a0e15 100644 --- a/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v2.verified.txt +++ b/src/OpenApi/test/Integration/snapshots/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=v2.verified.txt @@ -43,106 +43,15 @@ } } } - }, - "/getbyidandname/{id}/{name}": { - "get": { - "tags": [ - "Test" - ], - "parameters": [ - { - "name": "Id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "Name", - "in": "path", - "required": true, - "schema": { - "minLength": 5, - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - }, - "application/json": { - "schema": { - "type": "string" - } - }, - "text/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/forms": { - "post": { - "tags": [ - "Test" - ], - "requestBody": { - "content": { - "application/x-www-form-urlencoded": { - "schema": { - "type": "object", - "properties": { - "Title": { - "minLength": 5, - "type": "string" - }, - "Description": { - "minLength": 5, - "type": "string" - }, - "IsCompleted": { - "type": "boolean" - } - } - }, - "encoding": { - "application/x-www-form-urlencoded": { - "style": "form", - "explode": true - } - } - } - } - }, - "responses": { - "200": { - "description": "OK" - } - } - } } }, + "components": { }, "tags": [ { "name": "users" }, { "name": "Sample" - }, - { - "name": "Test" } ] } \ No newline at end of file diff --git a/src/OpenApi/test/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.RequestBody.cs b/src/OpenApi/test/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.RequestBody.cs index ad6d01ae8a79..7ebd6039bc2e 100644 --- a/src/OpenApi/test/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.RequestBody.cs +++ b/src/OpenApi/test/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.RequestBody.cs @@ -195,7 +195,7 @@ await VerifyOpenApiDocument(builder, document => { Assert.NotNull(allOfItem.Properties); Assert.Contains("formFile1", allOfItem.Properties); - var formFile1Property = allOfItem.Properties["formFile1"]; + var formFile1Property = allOfItem.Properties["formFile1"].GetEffective(document); Assert.Equal("string", formFile1Property.Type); Assert.Equal("binary", formFile1Property.Format); }, @@ -203,7 +203,7 @@ await VerifyOpenApiDocument(builder, document => { Assert.NotNull(allOfItem.Properties); Assert.Contains("formFile2", allOfItem.Properties); - var formFile2Property = allOfItem.Properties["formFile2"]; + var formFile2Property = allOfItem.Properties["formFile2"].GetEffective(document); Assert.Equal("string", formFile2Property.Type); Assert.Equal("binary", formFile2Property.Format); }); @@ -536,12 +536,12 @@ await VerifyOpenApiDocument(builder, document => Assert.Collection(allOfItem.Properties, property => { Assert.Equal("id", property.Key); - Assert.Equal("integer", property.Value.Type); + Assert.Equal("integer", property.Value.GetEffective(document).Type); }, property => { Assert.Equal("title", property.Key); - Assert.Equal("string", property.Value.Type); + Assert.Equal("string", property.Value.GetEffective(document).Type); }, property => { @@ -561,12 +561,12 @@ await VerifyOpenApiDocument(builder, document => property => { Assert.Equal("code", property.Key); - Assert.Equal("integer", property.Value.Type); + Assert.Equal("integer", property.Value.GetEffective(document).Type); }, property => { Assert.Equal("message", property.Key); - Assert.Equal("string", property.Value.Type); + Assert.Equal("string", property.Value.GetEffective(document).Type); }); }); } @@ -601,12 +601,12 @@ await VerifyOpenApiDocument(action, document => Assert.Collection(allOfItem.Properties, property => { Assert.Equal("Id", property.Key); - Assert.Equal("integer", property.Value.Type); + Assert.Equal("integer", property.Value.GetEffective(document).Type); }, property => { Assert.Equal("Title", property.Key); - Assert.Equal("string", property.Value.Type); + Assert.Equal("string", property.Value.GetEffective(document).Type); }, property => { @@ -626,12 +626,12 @@ await VerifyOpenApiDocument(action, document => property => { Assert.Equal("Code", property.Key); - Assert.Equal("integer", property.Value.Type); + Assert.Equal("integer", property.Value.GetEffective(document).Type); }, property => { Assert.Equal("Message", property.Key); - Assert.Equal("string", property.Value.Type); + Assert.Equal("string", property.Value.GetEffective(document).Type); }); }); } @@ -701,18 +701,18 @@ await VerifyOpenApiDocument(action, document => property => { Assert.Equal("Name", property.Key); - Assert.Equal("string", property.Value.Type); + Assert.Equal("string", property.Value.GetEffective(document).Type); }, property => { Assert.Equal("Description", property.Key); - Assert.Equal("string", property.Value.Type); + Assert.Equal("string", property.Value.GetEffective(document).Type); }, property => { Assert.Equal("Resume", property.Key); - Assert.Equal("string", property.Value.Type); - Assert.Equal("binary", property.Value.Format); + Assert.Equal("string", property.Value.GetEffective(document).Type); + Assert.Equal("binary", property.Value.GetEffective(document).Format); }); }); } @@ -744,12 +744,12 @@ await VerifyOpenApiDocument(builder, document => property => { Assert.Equal("name", property.Key); - Assert.Equal("string", property.Value.Type); + Assert.Equal("string", property.Value.GetEffective(document).Type); }, property => { Assert.Equal("description", property.Key); - Assert.Equal("string", property.Value.Type); + Assert.Equal("string", property.Value.GetEffective(document).Type); }, property => { @@ -1005,8 +1005,8 @@ await VerifyOpenApiDocument(builder, document => var content = Assert.Single(operation.RequestBody.Content); Assert.Equal("application/octet-stream", content.Key); Assert.NotNull(content.Value.Schema); - Assert.Equal("string", content.Value.Schema.Type); - Assert.Equal("binary", content.Value.Schema.Format); + Assert.Equal("string", content.Value.Schema.GetEffective(document).Type); + Assert.Equal("binary", content.Value.Schema.GetEffective(document).Format); } }); } diff --git a/src/OpenApi/test/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Responses.cs b/src/OpenApi/test/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Responses.cs index cbbda42c57cf..af2d994fe972 100644 --- a/src/OpenApi/test/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Responses.cs +++ b/src/OpenApi/test/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Responses.cs @@ -264,14 +264,16 @@ await VerifyOpenApiDocument(builder, document => Assert.NotNull(defaultResponse); Assert.Empty(defaultResponse.Description); var defaultContent = Assert.Single(defaultResponse.Content.Values); - Assert.Collection(defaultContent.Schema.Properties, property => + Assert.Collection(defaultContent.Schema.Properties, + property => { Assert.Equal("code", property.Key); - Assert.Equal("integer", property.Value.Type); - }, property => + Assert.Equal("integer", property.Value.GetEffective(document).Type); + }, + property => { Assert.Equal("message", property.Key); - Assert.Equal("string", property.Value.Type); + Assert.Equal("string", property.Value.GetEffective(document).Type); }); // Generates the 200 status code response with the `Todo` response type. var okResponse = operation.Responses["200"]; @@ -284,11 +286,11 @@ await VerifyOpenApiDocument(builder, document => Assert.Collection(schema.Properties, property => { Assert.Equal("id", property.Key); - Assert.Equal("integer", property.Value.Type); + Assert.Equal("integer", property.Value.GetEffective(document).Type); }, property => { Assert.Equal("title", property.Key); - Assert.Equal("string", property.Value.Type); + Assert.Equal("string", property.Value.GetEffective(document).Type); }, property => { Assert.Equal("completed", property.Key); diff --git a/src/OpenApi/test/Services/OpenApiSchemaService/OpenApiComponentService.RequestBodySchemas.cs b/src/OpenApi/test/Services/OpenApiSchemaService/OpenApiComponentService.RequestBodySchemas.cs index 624a71d77cfc..5677ea5c8cb2 100644 --- a/src/OpenApi/test/Services/OpenApiSchemaService/OpenApiComponentService.RequestBodySchemas.cs +++ b/src/OpenApi/test/Services/OpenApiSchemaService/OpenApiComponentService.RequestBodySchemas.cs @@ -174,8 +174,10 @@ await VerifyOpenApiDocument(builder, document => var operation = document.Paths[$"/{path}"].Operations[OperationType.Post]; var requestBody = operation.RequestBody; - Assert.Equal("string", requestBody.Content["application/octet-stream"].Schema.Type); - Assert.Equal("binary", requestBody.Content["application/octet-stream"].Schema.Format); + var effectiveSchema = requestBody.Content["application/octet-stream"].Schema; + + Assert.Equal("string", effectiveSchema.Type); + Assert.Equal("binary", effectiveSchema.Format); } }); } @@ -230,18 +232,22 @@ await VerifyOpenApiDocument(builder, document => Assert.NotNull(arrayTodo.RequestBody); var parameter = Assert.Single(arrayParsable.Parameters); + var enumerableTodoSchema = enumerableTodo.RequestBody.Content["application/json"].Schema; + var arrayTodoSchema = arrayTodo.RequestBody.Content["application/json"].Schema; + // Assert that both IEnumerable and Todo[] map to the same schemas + Assert.Equal(enumerableTodoSchema.Reference.Id, arrayTodoSchema.Reference.Id); // Assert all types materialize as arrays - Assert.Equal("array", enumerableTodo.RequestBody.Content["application/json"].Schema.Type); - Assert.Equal("array", arrayTodo.RequestBody.Content["application/json"].Schema.Type); + Assert.Equal("array", enumerableTodoSchema.GetEffective(document).Type); + Assert.Equal("array", arrayTodoSchema.GetEffective(document).Type); Assert.Equal("array", parameter.Schema.Type); Assert.Equal("string", parameter.Schema.Items.Type); Assert.Equal("uuid", parameter.Schema.Items.Format); // Assert the array items are the same as the Todo schema - foreach (var element in new[] { enumerableTodo, arrayTodo }) + foreach (var element in new[] { enumerableTodoSchema, arrayTodoSchema }) { - Assert.Collection(element.RequestBody.Content["application/json"].Schema.Items.Properties, + Assert.Collection(element.GetEffective(document).Items.GetEffective(document).Properties, property => { Assert.Equal("id", property.Key); diff --git a/src/OpenApi/test/Services/OpenApiSchemaService/OpenApiComponentService.ResponseSchemas.cs b/src/OpenApi/test/Services/OpenApiSchemaService/OpenApiComponentService.ResponseSchemas.cs index d1deebba81ec..33ef322a2b8a 100644 --- a/src/OpenApi/test/Services/OpenApiSchemaService/OpenApiComponentService.ResponseSchemas.cs +++ b/src/OpenApi/test/Services/OpenApiSchemaService/OpenApiComponentService.ResponseSchemas.cs @@ -225,8 +225,11 @@ await VerifyOpenApiDocument(builder, document => property => { Assert.Equal("dueDate", property.Key); - Assert.Equal("string", property.Value.Type); - Assert.Equal("date-time", property.Value.Format); + // DateTime schema appears twice in the document so we expect + // this to map to a reference ID. + var dateTimeSchema = property.Value.GetEffective(document); + Assert.Equal("string", dateTimeSchema.Type); + Assert.Equal("date-time", dateTimeSchema.Format); }, property => { @@ -247,8 +250,11 @@ await VerifyOpenApiDocument(builder, document => property => { Assert.Equal("createdAt", property.Key); - Assert.Equal("string", property.Value.Type); - Assert.Equal("date-time", property.Value.Format); + // DateTime schema appears twice in the document so we expect + // this to map to a reference ID. + var dateTimeSchema = property.Value.GetEffective(document); + Assert.Equal("string", dateTimeSchema.Type); + Assert.Equal("date-time", dateTimeSchema.Format); }); }); } @@ -485,14 +491,14 @@ await VerifyOpenApiDocument(builder, document => property => { Assert.Equal("pageIndex", property.Key); - Assert.Equal("integer", property.Value.Type); - Assert.Equal("int32", property.Value.Format); + Assert.Equal("integer", property.Value.GetEffective(document).Type); + Assert.Equal("int32", property.Value.GetEffective(document).Format); }, property => { Assert.Equal("pageSize", property.Key); - Assert.Equal("integer", property.Value.Type); - Assert.Equal("int32", property.Value.Format); + Assert.Equal("integer", property.Value.GetEffective(document).Type); + Assert.Equal("int32", property.Value.GetEffective(document).Format); }, property => { @@ -503,8 +509,8 @@ await VerifyOpenApiDocument(builder, document => property => { Assert.Equal("totalPages", property.Key); - Assert.Equal("integer", property.Value.Type); - Assert.Equal("int32", property.Value.Format); + Assert.Equal("integer", property.Value.GetEffective(document).Type); + Assert.Equal("int32", property.Value.GetEffective(document).Format); }, property => { @@ -516,8 +522,8 @@ await VerifyOpenApiDocument(builder, document => property => { Assert.Equal("id", property.Key); - Assert.Equal("integer", property.Value.Type); - Assert.Equal("int32", property.Value.Format); + Assert.Equal("integer", property.Value.GetEffective(document).Type); + Assert.Equal("int32", property.Value.GetEffective(document).Format); }, property => { @@ -559,16 +565,19 @@ await VerifyOpenApiDocument(builder, document => var response = responses.Value; Assert.True(response.Content.TryGetValue("application/problem+json", out var mediaType)); Assert.Equal("object", mediaType.Schema.Type); + // `string` schemas appear multiple times in this document so they should + // all resolve to reference IDs, hence the use of `GetEffective` to resolve the + // final schema. Assert.Collection(mediaType.Schema.Properties, property => { Assert.Equal("type", property.Key); - Assert.Equal("string", property.Value.Type); + Assert.Equal("string", property.Value.GetEffective(document).Type); }, property => { Assert.Equal("title", property.Key); - Assert.Equal("string", property.Value.Type); + Assert.Equal("string", property.Value.GetEffective(document).Type); }, property => { @@ -579,12 +588,12 @@ await VerifyOpenApiDocument(builder, document => property => { Assert.Equal("detail", property.Key); - Assert.Equal("string", property.Value.Type); + Assert.Equal("string", property.Value.GetEffective(document).Type); }, property => { Assert.Equal("instance", property.Key); - Assert.Equal("string", property.Value.Type); + Assert.Equal("string", property.Value.GetEffective(document).Type); }, property => { @@ -593,7 +602,7 @@ await VerifyOpenApiDocument(builder, document => // The errors object is a dictionary of string[]. Use `additionalProperties` // to indicate that the payload can be arbitrary keys with string[] values. Assert.Equal("array", property.Value.AdditionalProperties.Type); - Assert.Equal("string", property.Value.AdditionalProperties.Items.Type); + Assert.Equal("string", property.Value.AdditionalProperties.Items.GetEffective(document).Type); }); }); } diff --git a/src/OpenApi/test/SharedTypes.cs b/src/OpenApi/test/SharedTypes.cs index 781a29eb8d5e..15e09e44a099 100644 --- a/src/OpenApi/test/SharedTypes.cs +++ b/src/OpenApi/test/SharedTypes.cs @@ -101,3 +101,15 @@ internal class ProjectBoard public required bool IsPrivate { get; set; } } #nullable restore + +internal class Account +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; +} + +internal class Product +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; +} diff --git a/src/OpenApi/test/Transformers/Implementations/OpenApiSchemaReferenceTransformerTests.cs b/src/OpenApi/test/Transformers/Implementations/OpenApiSchemaReferenceTransformerTests.cs new file mode 100644 index 000000000000..57578a37cc5b --- /dev/null +++ b/src/OpenApi/test/Transformers/Implementations/OpenApiSchemaReferenceTransformerTests.cs @@ -0,0 +1,317 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; + +public class OpenApiSchemaReferenceTransformerTests : OpenApiDocumentServiceTestBase +{ + [Fact] + public async Task IdenticalParameterTypesAreStoredWithSchemaReference() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/api", (IFormFile value) => { }); + builder.MapPost("/api-2", (IFormFile value) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api"].Operations[OperationType.Post]; + var parameter = operation.RequestBody.Content["multipart/form-data"]; + var schema = parameter.Schema; + + var operation2 = document.Paths["/api-2"].Operations[OperationType.Post]; + var parameter2 = operation2.RequestBody.Content["multipart/form-data"]; + var schema2 = parameter2.Schema; + + // { + // "$ref": "#/components/schemas/IFormFileValue" + // } + // { + // "components": { + // "schemas": { + // "IFormFileValue": { + // "type": "object", + // "properties": { + // "value": { + // "$ref": "#/components/schemas/IFormFile" + // } + // } + // }, + // "IFormFile": { + // "type": "string", + // "format": "binary" + // } + // } + // } + Assert.Equal(schema.Reference, schema2.Reference); + + var effectiveSchema = schema.GetEffective(document); + Assert.Equal("object", effectiveSchema.Type); + Assert.Equal(1, effectiveSchema.Properties.Count); + var effectivePropertySchema = effectiveSchema.Properties["value"].GetEffective(document); + Assert.Equal("string", effectivePropertySchema.Type); + Assert.Equal("binary", effectivePropertySchema.Format); + }); + } + + [Fact] + public async Task TodoInRequestBodyAndResponseUsesSchemaReference() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/api", (Todo todo) => TypedResults.Ok(todo)); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api"].Operations[OperationType.Post]; + var requestBody = operation.RequestBody.Content["application/json"]; + var requestBodySchema = requestBody.Schema; + + var response = operation.Responses["200"]; + var responseContent = response.Content["application/json"]; + var responseSchema = responseContent.Schema; + + // { + // "$ref": "#/components/schemas/Todo" + // } + // { + // "components": { + // "schemas": { + // "Todo": { + // "type": "object", + // "properties": { + // "id": { + // "type": "integer" + // }, + // ... + // } + // } + // } + // } + Assert.Equal(requestBodySchema.Reference.Id, responseSchema.Reference.Id); + + var effectiveSchema = requestBodySchema.GetEffective(document); + Assert.Equal("object", effectiveSchema.Type); + Assert.Equal(4, effectiveSchema.Properties.Count); + var effectiveIdSchema = effectiveSchema.Properties["id"].GetEffective(document); + Assert.Equal("integer", effectiveIdSchema.Type); + var effectiveTitleSchema = effectiveSchema.Properties["title"].GetEffective(document); + Assert.Equal("string", effectiveTitleSchema.Type); + var effectiveCompletedSchema = effectiveSchema.Properties["completed"].GetEffective(document); + Assert.Equal("boolean", effectiveCompletedSchema.Type); + var effectiveCreatedAtSchema = effectiveSchema.Properties["createdAt"].GetEffective(document); + Assert.Equal("string", effectiveCreatedAtSchema.Type); + }); + } + + [Fact] + public async Task SameTypeInDictionaryAndListTypesUsesReferenceIds() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/api", (Todo[] todo) => { }); + builder.MapPost("/api-2", (Dictionary todo) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api"].Operations[OperationType.Post]; + var requestBody = operation.RequestBody.Content["application/json"]; + var requestBodySchema = requestBody.Schema; + + var operation2 = document.Paths["/api-2"].Operations[OperationType.Post]; + var requestBody2 = operation2.RequestBody.Content["application/json"]; + var requestBodySchema2 = requestBody2.Schema; + + // { + // "type": "array", + // "items": { + // "$ref": "#/components/schemas/Todo" + // } + // } + // { + // "type": "object", + // "additionalProperties": { + // "$ref": "#/components/schemas/Todo" + // } + // } + // { + // "components": { + // "schemas": { + // "Todo": { + // "type": "object", + // "properties": { + // "id": { + // "type": "integer" + // }, + // ... + // } + // } + // } + // } + // } + + // Parent types of schemas are different + Assert.Equal("array", requestBodySchema.Type); + Assert.Equal("object", requestBodySchema2.Type); + // Values of the list and dictionary point to the same reference ID + Assert.Equal(requestBodySchema.Items.Reference.Id, requestBodySchema2.AdditionalProperties.Reference.Id); + }); + } + + [Fact] + public async Task SameTypeInAllOfReferenceGetsHandledCorrectly() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/api", (IFormFile resume, [FromForm] Todo todo) => { }); + builder.MapPost("/api-2", ([FromForm] string name, [FromForm] Todo todo2) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api"].Operations[OperationType.Post]; + var requestBody = operation.RequestBody.Content["multipart/form-data"]; + var requestBodySchema = requestBody.Schema; + + var operation2 = document.Paths["/api-2"].Operations[OperationType.Post]; + var requestBody2 = operation2.RequestBody.Content["multipart/form-data"]; + var requestBodySchema2 = requestBody2.Schema; + + // Todo parameter (second parameter) in allOf for each operation should point to the same reference ID. + Assert.Equal(requestBodySchema.AllOf[1].Reference.Id, requestBodySchema2.AllOf[1].Reference.Id); + + // IFormFile parameter should use inline schema since it only appears once in the application. + Assert.Equal("object", requestBodySchema.AllOf[0].Type); + Assert.Equal("string", requestBodySchema.AllOf[0].Properties["resume"].Type); + Assert.Equal("binary", requestBodySchema.AllOf[0].Properties["resume"].Format); + + // String parameter `name` should use reference ID shared by string properties in the + // Todo object. + Assert.Equal("object", requestBodySchema2.AllOf[0].Type); + var nameParameterReference = requestBodySchema2.AllOf[0].Properties["name"].Reference.Id; + var todoTitleReference = requestBodySchema.AllOf[1].GetEffective(document).Properties["title"].Reference.Id; + var todoTitleReference2 = requestBodySchema2.AllOf[1].GetEffective(document).Properties["title"].Reference.Id; + Assert.Equal(nameParameterReference, todoTitleReference); + Assert.Equal(nameParameterReference, todoTitleReference2); + }); + } + + [Fact] + public async Task DifferentTypesWithSameSchemaMapToSameReferenceId() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/api", (IEnumerable todo) => { }); + builder.MapPost("/api-2", (Todo[] todo) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api"].Operations[OperationType.Post]; + var requestBody = operation.RequestBody.Content["application/json"]; + var requestBodySchema = requestBody.Schema; + + var operation2 = document.Paths["/api-2"].Operations[OperationType.Post]; + var requestBody2 = operation2.RequestBody.Content["application/json"]; + var requestBodySchema2 = requestBody2.Schema; + + // { + // "$ref": "#/components/schemas/TodoArray" + // } + // { + // "$ref": "#/components/schemas/TodoArray" + // } + // { + // "components": { + // "schemas": { + // "TodoArray": { + // "type": "array", + // "items": { + // "$ref": "#/components/schemas/Todo" + // } + // } + // } + // } + // } + + // Both list types should point to the same reference ID + Assert.Equal(requestBodySchema.Reference.Id, requestBodySchema2.Reference.Id); + // The referenced schema has an array type + Assert.Equal("array", requestBodySchema.GetEffective(document).Type); + // The items in the array are mapped to the Todo reference + Assert.NotNull(requestBodySchema.GetEffective(document).Items.Reference.Id); + Assert.Equal(4, requestBodySchema.GetEffective(document).Items.GetEffective(document).Properties.Count); + }); + } + + [Fact] + public async Task TypeModifiedWithSchemaTransformerMapsToDifferentReferenceId() + { + var builder = CreateBuilder(); + + builder.MapPost("/todo", (Todo todo) => { }); + builder.MapGet("/todo", () => new Todo(1, "Item1", false, DateTime.Now)); + + var options = new OpenApiOptions(); + options.UseSchemaTransformer((schema, context, cancellationToken) => + { + if (context.Type == typeof(Todo) && context.ParameterDescription is not null) + { + schema.Extensions["x-my-extension"] = new OpenApiString(context.ParameterDescription.Name); + } + return Task.CompletedTask; + }); + + await VerifyOpenApiDocument(builder, options, document => + { + var path = Assert.Single(document.Paths.Values); + var postOperation = path.Operations[OperationType.Post]; + var requestSchema = postOperation.RequestBody.Content["application/json"].Schema; + // Schemas are distinct because of applied transformer so no reference is used. + Assert.Null(requestSchema.Reference); + Assert.Equal("todo", ((OpenApiString)requestSchema.Extensions["x-my-extension"]).Value); + var getOperation = path.Operations[OperationType.Get]; + var responseSchema = getOperation.Responses["200"].Content["application/json"].Schema; + Assert.False(responseSchema.Extensions.TryGetValue("x-my-extension", out var _)); + // Schemas are distinct because of applied transformer so no reference is used. + Assert.Null(responseSchema.Reference); + + // References are still created for common types within the complex object (boolean, int, etc.) + Assert.Collection(document.Components.Schemas.Keys, + key => + { + Assert.Equal("boolean", key); + }, + key => + { + Assert.Equal("DateTime", key); + }, + key => + { + Assert.Equal("int", key); + }, + key => + { + Assert.Equal("string", key); + }); + }); + } +} diff --git a/src/OpenApi/test/Transformers/SchemaTransformerTests.cs b/src/OpenApi/test/Transformers/SchemaTransformerTests.cs index 841c4fd321e4..291a331e4fa8 100644 --- a/src/OpenApi/test/Transformers/SchemaTransformerTests.cs +++ b/src/OpenApi/test/Transformers/SchemaTransformerTests.cs @@ -134,10 +134,10 @@ await VerifyOpenApiDocument(builder, options, document => { var path = Assert.Single(document.Paths.Values); var postOperation = path.Operations[OperationType.Post]; - var requestSchema = postOperation.RequestBody.Content["application/json"].Schema; + var requestSchema = postOperation.RequestBody.Content["application/json"].Schema.GetEffective(document); Assert.Equal("1", ((OpenApiString)requestSchema.Extensions["x-my-extension"]).Value); var getOperation = path.Operations[OperationType.Get]; - var responseSchema = getOperation.Responses["200"].Content["application/json"].Schema; + var responseSchema = getOperation.Responses["200"].Content["application/json"].Schema.GetEffective(document); Assert.Equal("1", ((OpenApiString)responseSchema.Extensions["x-my-extension"]).Value); }); }