From 6295da7c4c9adf1b3d764c32ee31df5cc5c759e5 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Mon, 8 Apr 2024 09:51:02 -0700 Subject: [PATCH 1/4] Support generating request bodies for JSON and form inputs --- src/OpenApi/sample/Program.cs | 14 + .../Extensions/ApiDescriptionExtensions.cs | 32 ++ src/OpenApi/src/Extensions/TypeExtensions.cs | 19 + src/OpenApi/src/Extensions/TypeNameBuilder.cs | 326 +++++++++++++++ .../src/Services/OpenApiComponentService.cs | 25 ++ .../src/Services/OpenApiDocumentService.cs | 80 +++- .../test/Extensions/TypeExtensionsTests.cs | 65 +++ .../OpenApiDocumentServiceTests.Info.cs | 4 +- ...OpenApiDocumentServiceTests.RequestBody.cs | 391 ++++++++++++++++++ .../OpenApiDocumentServiceTestsBase.cs | 9 + 10 files changed, 962 insertions(+), 3 deletions(-) create mode 100644 src/OpenApi/src/Extensions/TypeExtensions.cs create mode 100644 src/OpenApi/src/Extensions/TypeNameBuilder.cs create mode 100644 src/OpenApi/test/Extensions/TypeExtensionsTests.cs create mode 100644 src/OpenApi/test/Services/OpenApiDocumentServiceTests.RequestBody.cs diff --git a/src/OpenApi/sample/Program.cs b/src/OpenApi/sample/Program.cs index 90e039c29f86..0ce2d85244ec 100644 --- a/src/OpenApi/sample/Program.cs +++ b/src/OpenApi/sample/Program.cs @@ -1,6 +1,7 @@ // 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.Mvc; using Microsoft.OpenApi.Models; using Sample.Transformers; @@ -21,6 +22,7 @@ }); }); builder.Services.AddOpenApi("responses"); +builder.Services.AddOpenApi("forms"); var app = builder.Build(); @@ -30,6 +32,18 @@ app.MapSwaggerUi(); } +var forms = app.MapGroup("forms") + .WithGroupName("forms"); + +if (app.Environment.IsDevelopment()) +{ + forms.DisableAntiforgery(); +} + +forms.MapPost("/form-file", (IFormFile resume) => Results.Ok(resume.FileName)); +forms.MapPost("/form-files", (IFormFileCollection files) => Results.Ok(files.Count)); +forms.MapPost("/form-todo", ([FromForm] Todo todo) => Results.Ok(todo)); + var v1 = app.MapGroup("v1") .WithGroupName("v1"); var v2 = app.MapGroup("v2") diff --git a/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs b/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs index 814475a82cb6..9e134604641a 100644 --- a/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs +++ b/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Text; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -81,4 +83,34 @@ public static bool IsRequestBodyParameter(this ApiParameterDescription apiParame apiParameterDescription.Source == BindingSource.Body || apiParameterDescription.Source == BindingSource.FormFile || apiParameterDescription.Source == BindingSource.Form; + + /// + /// Retrieves the form parameters from the ApiDescription, if they exist. + /// + /// The ApiDescription to resolve form parameters from. + /// A list of associated with the form parameters. + /// if form parameters were found, otherwise. + public static bool TryGetFormParameters(this ApiDescription apiDescription, out IEnumerable formParameters) + { + formParameters = apiDescription.ParameterDescriptions.Where(parameter => parameter.Source == BindingSource.Form || parameter.Source == BindingSource.FormFile); + return formParameters.Any(); + } + + /// + /// Retrieves the body parameter from the ApiDescription, if it exists. + /// + /// The ApiDescription to resolve the body parameter from. + /// The associated with the body parameter. + /// if a single body parameter was found, otherwise. + public static bool TryGetBodyParameter(this ApiDescription apiDescription, [NotNullWhen(true)] out ApiParameterDescription? bodyParameter) + { + bodyParameter = null; + var bodyParameters = apiDescription.ParameterDescriptions.Where(parameter => parameter.Source == BindingSource.Body); + if (bodyParameters.Count() == 1) + { + bodyParameter = bodyParameters.Single(); + return true; + } + return false; + } } diff --git a/src/OpenApi/src/Extensions/TypeExtensions.cs b/src/OpenApi/src/Extensions/TypeExtensions.cs new file mode 100644 index 000000000000..a3af316b51f6 --- /dev/null +++ b/src/OpenApi/src/Extensions/TypeExtensions.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.OpenApi; + +internal static class TypeExtensions +{ + /// + /// Gets the schema reference identifier for the given . + /// + /// The to resolve a schema reference identifier for. + /// The schema reference identifier associated with . + public static string GetSchemaReferenceId(this Type type) + { + var tnb = new TypeNameBuilder(); + tnb.AddAssemblyQualifiedName(type, TypeNameBuilder.Format.ToString); + return tnb.ToString(); + } +} diff --git a/src/OpenApi/src/Extensions/TypeNameBuilder.cs b/src/OpenApi/src/Extensions/TypeNameBuilder.cs new file mode 100644 index 000000000000..8211058826ea --- /dev/null +++ b/src/OpenApi/src/Extensions/TypeNameBuilder.cs @@ -0,0 +1,326 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Text; + +namespace Microsoft.AspNetCore.OpenApi; + +/// +/// This implementation is based off the `TypeNameBuilder` used by the runtime +/// to generate `Type.FullName`s. It's been modified slightly to satisfy our +/// OpenAPI requirements. Original implementation can be seen here: +/// https://github.com/dotnet/runtime/blob/d88c7ba88627b4b68ad523ba27cb354809eb7e67/src/libraries/System.Private.CoreLib/src/System/Reflection/Emit/TypeNameBuilder.cs +/// +internal sealed class TypeNameBuilder +{ + private readonly StringBuilder _str = new(); + private int _instNesting; + private bool _firstInstArg; + private bool _nestedName; + private bool _hasAssemblySpec; + private readonly List _stack = []; + private int _stackIdx; + + internal TypeNameBuilder() + { + } + + private void OpenGenericArguments() + { + _instNesting++; + _firstInstArg = true; + } + + private void CloseGenericArguments() + { + Debug.Assert(_instNesting != 0); + + _instNesting--; + + if (_firstInstArg) + { + _str.Remove(_str.Length - 1, 1); + } + } + + private void OpenGenericArgument() + { + Debug.Assert(_instNesting != 0); + + _nestedName = false; + + if (!_firstInstArg) + { + Append("And"); + } + + _firstInstArg = false; + + Append("Of"); + + PushOpenGenericArgument(); + } + + private void CloseGenericArgument() + { + Debug.Assert(_instNesting != 0); + + if (_hasAssemblySpec) + { + Append(']'); + } + } + + private void AddName(string name) + { + Debug.Assert(name != null); + + if (_nestedName) + { + Append('+'); + } + + _nestedName = true; + + EscapeName(name); + } + + private void AddArray(int rank) + { + Debug.Assert(rank > 0); + + if (rank == 1) + { + Append("[*]"); + } + else if (rank > 64) + { + // Only taken in an error path, runtime will not load arrays of more than 32 dimensions + _str.Append('[').Append(rank).Append(']'); + } + else + { + Append('['); + for (int i = 1; i < rank; i++) + { + Append(','); + } + + Append(']'); + } + } + + private void AddAssemblySpec(string assemblySpec) + { + if (assemblySpec != null && !assemblySpec.Equals("")) + { + Append(", "); + + if (_instNesting > 0) + { + EscapeEmbeddedAssemblyName(assemblySpec); + } + else + { + EscapeAssemblyName(assemblySpec); + } + + _hasAssemblySpec = true; + } + } + + public override string ToString() + { + Debug.Assert(_instNesting == 0); + + return _str.ToString(); + } + + private static bool ContainsReservedChar(string name) + { + foreach (char c in name) + { + if (c == '\0') + { + break; + } + + if (IsTypeNameReservedChar(c)) + { + return true; + } + } + return false; + } + + private static bool IsTypeNameReservedChar(char ch) + { + return ch switch + { + ',' or '[' or ']' or '&' or '*' or '+' or '\\' or '`' or '<' or '>' => true, + _ => false, + }; + } + + private void EscapeName(string name) + { + if (ContainsReservedChar(name)) + { + foreach (char c in name) + { + if (c == '\0') + { + break; + } + + if (char.IsDigit(c)) + { + continue; + } + + if (IsTypeNameReservedChar(c)) + { + continue; + } + + _str.Append(c); + } + } + else + { + Append(name); + } + } + + private void EscapeAssemblyName(string name) + { + Append(name); + } + + private void EscapeEmbeddedAssemblyName(string name) + { + if (name.Contains(']')) + { + foreach (char c in name) + { + if (c == ']') + { + Append('\\'); + } + + Append(c); + } + } + else + { + Append(name); + } + } + + private void PushOpenGenericArgument() + { + _stack.Add(_str.Length); + _stackIdx++; + } + + private void Append(string pStr) + { + int i = pStr.IndexOf('\0'); + if (i < 0) + { + _str.Append(pStr); + } + else if (i > 0) + { + _str.Append(pStr.AsSpan(0, i)); + } + } + + private void Append(char c) + { + _str.Append(c); + } + + internal enum Format + { + ToString, + FullName, + AssemblyQualifiedName, + } + + internal static string? ToString(Type type, Format format) + { + if (format == Format.FullName || format == Format.AssemblyQualifiedName) + { + if (!type.IsGenericTypeDefinition && type.ContainsGenericParameters) + { + return null; + } + } + + var tnb = new TypeNameBuilder(); + tnb.AddAssemblyQualifiedName(type, format); + return tnb.ToString(); + } + + private void AddElementType(Type type) + { + if (!type.HasElementType) + { + return; + } + + AddElementType(type.GetElementType()!); + + if (type.IsPointer) + { + Append('*'); + } + else if (type.IsByRef) + { + Append('&'); + } + else if (type.IsSZArray) + { + Append("[]"); + } + else if (type.IsArray) + { + AddArray(type.GetArrayRank()); + } + } + + internal void AddAssemblyQualifiedName(Type type, Format format) + { + // Append just the type name to the start because + // we don't want to include the fully qualified name + // in the OpenAPI document. + AddName(type.Name); + + // Append generic arguments + if (type.IsGenericType && (!type.IsGenericTypeDefinition || format == Format.ToString)) + { + Type[] genericArguments = type.GetGenericArguments(); + + OpenGenericArguments(); + for (int i = 0; i < genericArguments.Length; i++) + { + Format genericArgumentsFormat = format == Format.FullName ? Format.AssemblyQualifiedName : format; + + OpenGenericArgument(); + AddAssemblyQualifiedName(genericArguments[i], genericArgumentsFormat); + CloseGenericArgument(); + } + CloseGenericArguments(); + } + + // Append pointer, byRef and array qualifiers + AddElementType(type); + + if (format == Format.AssemblyQualifiedName) + { + AddAssemblySpec(type.Module.Assembly.FullName!); + } + } +} diff --git a/src/OpenApi/src/Services/OpenApiComponentService.cs b/src/OpenApi/src/Services/OpenApiComponentService.cs index f327ee1c34b3..c9633bac0aec 100644 --- a/src/OpenApi/src/Services/OpenApiComponentService.cs +++ b/src/OpenApi/src/Services/OpenApiComponentService.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.Concurrent; +using Microsoft.AspNetCore.Http; +using Microsoft.OpenApi.Models; + namespace Microsoft.AspNetCore.OpenApi; /// @@ -10,4 +14,25 @@ namespace Microsoft.AspNetCore.OpenApi; /// internal sealed class OpenApiComponentService { + private readonly ConcurrentDictionary _schemas = new() + { + // Pre-populate OpenAPI schemas for well-defined types in ASP.NET Core. + [typeof(IFormFile).GetSchemaReferenceId()] = new OpenApiSchema { Type = "string", Format = "binary" }, + [typeof(IFormFileCollection).GetSchemaReferenceId()] = new OpenApiSchema + { + Type = "array", + Items = new OpenApiSchema { Type = "string", Format = "binary" } + }, + }; + + internal OpenApiSchema GetOrCreateSchema(Type type) + { + var schemaId = type.GetSchemaReferenceId(); + return _schemas.GetOrAdd(schemaId, _ => CreateSchema()); + } + + internal static OpenApiSchema CreateSchema() + { + return new OpenApiSchema { Type = "string" }; + } } diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index faca9d9b759d..d96ec88905c8 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -28,6 +28,9 @@ internal sealed class OpenApiDocumentService( IServiceProvider serviceProvider) { private readonly OpenApiOptions _options = optionsMonitor.Get(documentName); + private readonly OpenApiComponentService _componentService = serviceProvider.GetRequiredKeyedService(documentName); + + private static readonly OpenApiEncoding _defaultFormEncoding = new OpenApiEncoding { Style = ParameterStyle.Form, Explode = true }; /// /// Cache of instances keyed by the @@ -124,7 +127,7 @@ private Dictionary GetOperations(IGrouping capturedTags) + private OpenApiOperation GetOperation(ApiDescription description, HashSet capturedTags) { var tags = GetTags(description); if (tags != null) @@ -140,6 +143,7 @@ private static OpenApiOperation GetOperation(ApiDescription description, HashSet Description = GetDescription(description), Responses = GetResponses(description), Parameters = GetParameters(description), + RequestBody = GetRequestBody(description), Tags = tags, }; return operation; @@ -256,4 +260,78 @@ private static OpenApiResponse GetResponse(ApiDescription apiDescription, int st } return parameters; } + + private OpenApiRequestBody? GetRequestBody(ApiDescription description) + { + // Only one parameter can be bound from the body in each request. + if (description.TryGetBodyParameter(out var bodyParameter)) + { + return GetJsonRequestBody(description.SupportedRequestFormats, bodyParameter); + } + // If there are no body parameters, check for form parameters. + // Note: Form parameters and body parameters cannot exist simultaneously + // in the same endpoint. + if (description.TryGetFormParameters(out var formParameters)) + { + return GetFormRequestBody(description.SupportedRequestFormats, formParameters); + } + return null; + } + + private OpenApiRequestBody GetFormRequestBody(IList supportedRequestFormats, IEnumerable formParameters) + { + if (supportedRequestFormats.Count == 0) + { + // Assume "application/x-www-form-urlencoded" as the default media type + // to match the default assumed in IFormFeature. + supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/x-www-form-urlencoded" }]; + } + + var requestBody = new OpenApiRequestBody + { + Required = formParameters.Any(parameter => parameter.IsRequired), + Content = new Dictionary() + }; + + // Forms are represented as objects with properties for each form field. + var schema = new OpenApiSchema { Type = "object", Properties = new Dictionary() }; + foreach (var parameter in formParameters) + { + schema.Properties[parameter.Name] = _componentService.GetOrCreateSchema(parameter.Type); + } + + foreach (var requestFormat in supportedRequestFormats) + { + var contentType = requestFormat.MediaType; + requestBody.Content[contentType] = new OpenApiMediaType + { + Schema = schema, + Encoding = new Dictionary() { [contentType] = _defaultFormEncoding } + }; + } + + return requestBody; + } + + private static OpenApiRequestBody GetJsonRequestBody(IList supportedRequestFormats, ApiParameterDescription bodyParameter) + { + if (supportedRequestFormats.Count == 0) + { + supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/json" }]; + } + + var requestBody = new OpenApiRequestBody + { + Required = bodyParameter.IsRequired, + Content = new Dictionary() + }; + + foreach (var requestForm in supportedRequestFormats) + { + var contentType = requestForm.MediaType; + requestBody.Content[contentType] = new OpenApiMediaType(); + } + + return requestBody; + } } diff --git a/src/OpenApi/test/Extensions/TypeExtensionsTests.cs b/src/OpenApi/test/Extensions/TypeExtensionsTests.cs new file mode 100644 index 000000000000..fe39b5d9b129 --- /dev/null +++ b/src/OpenApi/test/Extensions/TypeExtensionsTests.cs @@ -0,0 +1,65 @@ +// 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.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.OpenApi; + +public class TypeExtensionsTests +{ + private delegate void TestDelegate(int x, int y); + + private class Container + { + internal delegate void ContainedTestDelegate(int x, int y); + } + + private class Outer + { + public class Inner + { + } + public class Inner2 + { + } + } + + private class Outer + { + public class Inner + { + } + } + + public static IEnumerable GetSchemaReferenceId_Data => + [ + [typeof(Todo), "Todo"], + [typeof(IEnumerable), "IEnumerableOfTodo"], + [typeof(TodoWithDueDate), "TodoWithDueDate"], + [typeof(IEnumerable), "IEnumerableOfTodoWithDueDate"], + [(new { Id = 1 }).GetType(), "Int32AnonymousType"], + [(new { Id = 1, Name = "Todo" }).GetType(), "Int32StringAnonymousType"], + [typeof(IFormFile), "IFormFile"], + [typeof(IFormFileCollection), "IFormFileCollection"], + [typeof(Results, Ok>), "ResultsOfOkOfTodoWithDueDateAndOfOkOfTodo"], + [typeof(TestDelegate), "TestDelegate"], + [typeof(Container.ContainedTestDelegate), "ContainedTestDelegate"], + [(new int[2, 3]).GetType(), "Int32Array2Array3"], + [typeof(List), "ListOfInt32"], + [typeof(List<>), "ListOfT"], + [typeof(List>), "ListOfListOfInt32"], + [typeof(int[]), "Array"], + [typeof(int[,]), "Array"], + [typeof(Outer<>.Inner), ""], + [typeof(Outer.Inner), "InnerOfInt32"], + [typeof(Outer<>.Inner2<>), ""], + [typeof(Outer.Inner2), ""], + [typeof(Outer.Inner<>), "InnerOfT"], + [typeof(Outer.Inner), "InnerOfInt32"], + ]; + + [Theory] + [MemberData(nameof(GetSchemaReferenceId_Data))] + public void GetSchemaReferenceId_Works(Type type, string referenceId) + => Assert.Equal(referenceId, type.GetSchemaReferenceId()); +} diff --git a/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Info.cs b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Info.cs index 3145eb5c4553..3f201381c0f7 100644 --- a/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Info.cs +++ b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Info.cs @@ -23,7 +23,7 @@ public void GetOpenApiInfo_RespectsHostEnvironmentName() new Mock().Object, hostEnvironment, new Mock>().Object, - new Mock().Object); + new Mock().Object); // Act var info = docService.GetOpenApiInfo(); @@ -45,7 +45,7 @@ public void GetOpenApiInfo_RespectsDocumentName() new Mock().Object, hostEnvironment, new Mock>().Object, - new Mock().Object); + new Mock().Object); // Act var info = docService.GetOpenApiInfo(); diff --git a/src/OpenApi/test/Services/OpenApiDocumentServiceTests.RequestBody.cs b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.RequestBody.cs new file mode 100644 index 000000000000..16604a533fe8 --- /dev/null +++ b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.RequestBody.cs @@ -0,0 +1,391 @@ +// 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.OpenApi.Models; + +public partial class OpenApiDocumentServiceTests : OpenApiDocumentServiceTestBase +{ + [Fact] + public async Task GetRequestBody_VerifyDefaultFormEncoding() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/", (IFormFile formFile) => { }); + + // Assert -- The defaults for form encoding are Explode = true and Style = Form + // which align with the encoding formats that are used by ASP.NET Core's binding layer. + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("multipart/form-data", content.Key); + var encoding = content.Value.Encoding["multipart/form-data"]; + Assert.True(encoding.Explode); + Assert.Equal(ParameterStyle.Form, encoding.Style); + }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GetRequestBody_HandlesIFormFile(bool withAttribute) + { + // Arrange + var builder = CreateBuilder(); + + // Act + if (withAttribute) + { + builder.MapPost("/", ([FromForm] IFormFile formFile) => { }); + } + else + { + builder.MapPost("/", (IFormFile formFile) => { }); + } + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + Assert.False(operation.RequestBody.Required); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("multipart/form-data", content.Key); + Assert.Equal("object", content.Value.Schema.Type); + Assert.NotNull(content.Value.Schema.Properties); + Assert.Contains("formFile", content.Value.Schema.Properties); + var formFileProperty = content.Value.Schema.Properties["formFile"]; + Assert.Equal("string", formFileProperty.Type); + Assert.Equal("binary", formFileProperty.Format); + }); + } + +#nullable enable + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GetRequestBody_HandlesIFormFileOptionality(bool isOptional) + { + // Arrange + var builder = CreateBuilder(); + + // Act + if (isOptional) + { + builder.MapPost("/", (IFormFile? formFile) => { }); + } + else + { + builder.MapPost("/", (IFormFile formFile) => { }); + } + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + Assert.Equal(!isOptional, operation.RequestBody.Required); + }); + } +#nullable restore + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GetRequestBody_HandlesIFormFileCollection(bool withAttribute) + { + // Arrange + var builder = CreateBuilder(); + + // Act + if (withAttribute) + { + builder.MapPost("/", ([FromForm] IFormFileCollection formFileCollection) => { }); + } + else + { + builder.MapPost("/", (IFormFileCollection formFileCollection) => { }); + } + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + Assert.False(operation.RequestBody.Required); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("multipart/form-data", content.Key); + Assert.Equal("object", content.Value.Schema.Type); + Assert.NotNull(content.Value.Schema.Properties); + Assert.Contains("formFileCollection", content.Value.Schema.Properties); + var formFileProperty = content.Value.Schema.Properties["formFileCollection"]; + Assert.Equal("array", formFileProperty.Type); + Assert.Equal("string", formFileProperty.Items.Type); + Assert.Equal("binary", formFileProperty.Items.Format); + }); + } + +#nullable enable + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GetRequestBody_HandlesIFormFileCollectionOptionality(bool isOptional) + { + // Arrange + var builder = CreateBuilder(); + + // Act + if (isOptional) + { + builder.MapPost("/", (IFormFileCollection? formFile) => { }); + } + else + { + builder.MapPost("/", (IFormFileCollection formFile) => { }); + } + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + Assert.Equal(!isOptional, operation.RequestBody.Required); + }); + } +#nullable restore + + [Fact] + public async Task GetRequestBody_MultipleFormFileParameters() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/", (IFormFile formFile1, IFormFile formFile2) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("multipart/form-data", content.Key); + Assert.Equal("object", content.Value.Schema.Type); + Assert.NotNull(content.Value.Schema.Properties); + Assert.Contains("formFile1", content.Value.Schema.Properties); + Assert.Contains("formFile2", content.Value.Schema.Properties); + var formFile1Property = content.Value.Schema.Properties["formFile1"]; + Assert.Equal("string", formFile1Property.Type); + Assert.Equal("binary", formFile1Property.Format); + var formFile2Property = content.Value.Schema.Properties["formFile2"]; + Assert.Equal("string", formFile2Property.Type); + Assert.Equal("binary", formFile2Property.Format); + }); + } + + [Fact] + public async Task GetRequestBody_IFormFileHandlesAcceptsMetadata() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/", (IFormFile formFile) => { }).Accepts(typeof(IFormFile), "application/magic-foo-content-type"); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("application/magic-foo-content-type", content.Key); + Assert.Equal("object", content.Value.Schema.Type); + Assert.NotNull(content.Value.Schema.Properties); + Assert.Contains("formFile", content.Value.Schema.Properties); + var formFileProperty = content.Value.Schema.Properties["formFile"]; + Assert.Equal("string", formFileProperty.Type); + Assert.Equal("binary", formFileProperty.Format); + }); + } + + [Fact] + public async Task GetRequestBody_IFormFileHandlesConsumesAttribute() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/", [Consumes(typeof(IFormFile), "application/magic-foo-content-type")] (IFormFile formFile) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("application/magic-foo-content-type", content.Key); + Assert.Equal("object", content.Value.Schema.Type); + Assert.NotNull(content.Value.Schema.Properties); + Assert.Contains("formFile", content.Value.Schema.Properties); + var formFileProperty = content.Value.Schema.Properties["formFile"]; + Assert.Equal("string", formFileProperty.Type); + Assert.Equal("binary", formFileProperty.Format); + }); + } + + [Fact] + public async Task GetRequestBody_HandlesJsonBody() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/", (TodoWithDueDate name) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + Assert.False(operation.RequestBody.Required); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("application/json", content.Key); + }); + } + +#nullable enable + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GetRequestBody_HandlesJsonBodyOptionality(bool isOptional) + { + // Arrange + var builder = CreateBuilder(); + + // Act + if (isOptional) + { + builder.MapPost("/", (TodoWithDueDate? name) => { }); + } + else + { + builder.MapPost("/", (TodoWithDueDate name) => { }); + } + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + Assert.Equal(!isOptional, operation.RequestBody.Required); + }); + + } +#nullable restore + + [Fact] + public async Task GetRequestBody_HandlesJsonBodyWithAttribute() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/", ([FromBody] string name) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + Assert.False(operation.RequestBody.Required); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("application/json", content.Key); + }); + } + + [Fact] + public async Task GetRequestBody_HandlesJsonBodyWithAcceptsMetadata() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/", (string name) => { }).Accepts(typeof(string), "application/magic-foo-content-type"); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("application/magic-foo-content-type", content.Key); + }); + } + + [Fact] + public async Task GetRequestBody_HandlesJsonBodyWithConsumesAttribute() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/", [Consumes(typeof(string), "application/magic-foo-content-type")] (string name) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("application/magic-foo-content-type", content.Key); + }); + } + + [Fact] + public async Task GetOpenApiRequestBody_SetsNullRequestBodyWithNoParameters() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/", (string name) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.Null(operation.RequestBody); + }); + } + +} diff --git a/src/OpenApi/test/Services/OpenApiDocumentServiceTestsBase.cs b/src/OpenApi/test/Services/OpenApiDocumentServiceTestsBase.cs index c517dd7e9bd2..ac3bd3b6e3d4 100644 --- a/src/OpenApi/test/Services/OpenApiDocumentServiceTestsBase.cs +++ b/src/OpenApi/test/Services/OpenApiDocumentServiceTestsBase.cs @@ -91,6 +91,7 @@ private class TestServiceProvider : IServiceProvider, IKeyedServiceProvider public static TestServiceProvider Instance { get; } = new TestServiceProvider(); private IKeyedServiceProvider _serviceProvider; internal OpenApiDocumentService TestDocumentService { get; set; } + internal OpenApiComponentService TestComponentService { get; set; } = new OpenApiComponentService(); public void SetInternalServiceProvider(IServiceCollection serviceCollection) { @@ -103,6 +104,10 @@ public object GetKeyedService(Type serviceType, object serviceKey) { return TestDocumentService; } + if (serviceType == typeof(OpenApiComponentService)) + { + return TestComponentService; + } return _serviceProvider.GetKeyedService(serviceType, serviceKey); } @@ -113,6 +118,10 @@ public object GetRequiredKeyedService(Type serviceType, object serviceKey) { return TestDocumentService; } + if (serviceType == typeof(OpenApiComponentService)) + { + return TestComponentService; + } return _serviceProvider.GetRequiredKeyedService(serviceType, serviceKey); } From eadc5647bfe2052b8e1f021c25af52e18143e1df Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Thu, 11 Apr 2024 08:50:47 -0700 Subject: [PATCH 2/4] Update schema reference ID generation --- src/OpenApi/src/Extensions/TypeNameBuilder.cs | 16 +++------------- .../test/Extensions/TypeExtensionsTests.cs | 12 +++++++----- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/src/OpenApi/src/Extensions/TypeNameBuilder.cs b/src/OpenApi/src/Extensions/TypeNameBuilder.cs index 8211058826ea..4b949359092d 100644 --- a/src/OpenApi/src/Extensions/TypeNameBuilder.cs +++ b/src/OpenApi/src/Extensions/TypeNameBuilder.cs @@ -92,22 +92,12 @@ private void AddArray(int rank) if (rank == 1) { - Append("[*]"); - } - else if (rank > 64) - { - // Only taken in an error path, runtime will not load arrays of more than 32 dimensions - _str.Append('[').Append(rank).Append(']'); + Append("Array"); } else { - Append('['); - for (int i = 1; i < rank; i++) - { - Append(','); - } - - Append(']'); + // Only taken in an error path, runtime will not load arrays of more than 32 dimensions + _str.Append("ArrayOf").Append(rank); } } diff --git a/src/OpenApi/test/Extensions/TypeExtensionsTests.cs b/src/OpenApi/test/Extensions/TypeExtensionsTests.cs index fe39b5d9b129..58afb2fc10d5 100644 --- a/src/OpenApi/test/Extensions/TypeExtensionsTests.cs +++ b/src/OpenApi/test/Extensions/TypeExtensionsTests.cs @@ -42,18 +42,20 @@ public class Inner [typeof(IFormFile), "IFormFile"], [typeof(IFormFileCollection), "IFormFileCollection"], [typeof(Results, Ok>), "ResultsOfOkOfTodoWithDueDateAndOfOkOfTodo"], + [typeof(Ok), "OkOfTodo"], + [typeof(NotFound), "NotFoundOfTodoWithDueDate"], [typeof(TestDelegate), "TestDelegate"], [typeof(Container.ContainedTestDelegate), "ContainedTestDelegate"], - [(new int[2, 3]).GetType(), "Int32Array2Array3"], + [(new int[2, 3]).GetType(), "IntArrayOf2"], [typeof(List), "ListOfInt32"], [typeof(List<>), "ListOfT"], [typeof(List>), "ListOfListOfInt32"], [typeof(int[]), "Array"], - [typeof(int[,]), "Array"], - [typeof(Outer<>.Inner), ""], + [typeof(int[,]), "IntArrayOf2"], + [typeof(Outer<>.Inner), "InnerOfT"], [typeof(Outer.Inner), "InnerOfInt32"], - [typeof(Outer<>.Inner2<>), ""], - [typeof(Outer.Inner2), ""], + [typeof(Outer<>.Inner2<>), "InnerOfTAndOfU"], + [typeof(Outer.Inner2), "InnerOfInt32AndOfInt32"], [typeof(Outer.Inner<>), "InnerOfT"], [typeof(Outer.Inner), "InnerOfInt32"], ]; From 987a385733ed5a35ac11bc81db6c92297770e2b2 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Thu, 11 Apr 2024 08:58:16 -0700 Subject: [PATCH 3/4] Key schema cache by Type instead of reference ID --- src/OpenApi/src/Extensions/TypeExtensions.cs | 19 -- src/OpenApi/src/Extensions/TypeNameBuilder.cs | 316 ------------------ .../src/Services/OpenApiComponentService.cs | 9 +- .../test/Extensions/TypeExtensionsTests.cs | 67 ---- 4 files changed, 4 insertions(+), 407 deletions(-) delete mode 100644 src/OpenApi/src/Extensions/TypeExtensions.cs delete mode 100644 src/OpenApi/src/Extensions/TypeNameBuilder.cs delete mode 100644 src/OpenApi/test/Extensions/TypeExtensionsTests.cs diff --git a/src/OpenApi/src/Extensions/TypeExtensions.cs b/src/OpenApi/src/Extensions/TypeExtensions.cs deleted file mode 100644 index a3af316b51f6..000000000000 --- a/src/OpenApi/src/Extensions/TypeExtensions.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.AspNetCore.OpenApi; - -internal static class TypeExtensions -{ - /// - /// Gets the schema reference identifier for the given . - /// - /// The to resolve a schema reference identifier for. - /// The schema reference identifier associated with . - public static string GetSchemaReferenceId(this Type type) - { - var tnb = new TypeNameBuilder(); - tnb.AddAssemblyQualifiedName(type, TypeNameBuilder.Format.ToString); - return tnb.ToString(); - } -} diff --git a/src/OpenApi/src/Extensions/TypeNameBuilder.cs b/src/OpenApi/src/Extensions/TypeNameBuilder.cs deleted file mode 100644 index 4b949359092d..000000000000 --- a/src/OpenApi/src/Extensions/TypeNameBuilder.cs +++ /dev/null @@ -1,316 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Diagnostics; -using System.Text; - -namespace Microsoft.AspNetCore.OpenApi; - -/// -/// This implementation is based off the `TypeNameBuilder` used by the runtime -/// to generate `Type.FullName`s. It's been modified slightly to satisfy our -/// OpenAPI requirements. Original implementation can be seen here: -/// https://github.com/dotnet/runtime/blob/d88c7ba88627b4b68ad523ba27cb354809eb7e67/src/libraries/System.Private.CoreLib/src/System/Reflection/Emit/TypeNameBuilder.cs -/// -internal sealed class TypeNameBuilder -{ - private readonly StringBuilder _str = new(); - private int _instNesting; - private bool _firstInstArg; - private bool _nestedName; - private bool _hasAssemblySpec; - private readonly List _stack = []; - private int _stackIdx; - - internal TypeNameBuilder() - { - } - - private void OpenGenericArguments() - { - _instNesting++; - _firstInstArg = true; - } - - private void CloseGenericArguments() - { - Debug.Assert(_instNesting != 0); - - _instNesting--; - - if (_firstInstArg) - { - _str.Remove(_str.Length - 1, 1); - } - } - - private void OpenGenericArgument() - { - Debug.Assert(_instNesting != 0); - - _nestedName = false; - - if (!_firstInstArg) - { - Append("And"); - } - - _firstInstArg = false; - - Append("Of"); - - PushOpenGenericArgument(); - } - - private void CloseGenericArgument() - { - Debug.Assert(_instNesting != 0); - - if (_hasAssemblySpec) - { - Append(']'); - } - } - - private void AddName(string name) - { - Debug.Assert(name != null); - - if (_nestedName) - { - Append('+'); - } - - _nestedName = true; - - EscapeName(name); - } - - private void AddArray(int rank) - { - Debug.Assert(rank > 0); - - if (rank == 1) - { - Append("Array"); - } - else - { - // Only taken in an error path, runtime will not load arrays of more than 32 dimensions - _str.Append("ArrayOf").Append(rank); - } - } - - private void AddAssemblySpec(string assemblySpec) - { - if (assemblySpec != null && !assemblySpec.Equals("")) - { - Append(", "); - - if (_instNesting > 0) - { - EscapeEmbeddedAssemblyName(assemblySpec); - } - else - { - EscapeAssemblyName(assemblySpec); - } - - _hasAssemblySpec = true; - } - } - - public override string ToString() - { - Debug.Assert(_instNesting == 0); - - return _str.ToString(); - } - - private static bool ContainsReservedChar(string name) - { - foreach (char c in name) - { - if (c == '\0') - { - break; - } - - if (IsTypeNameReservedChar(c)) - { - return true; - } - } - return false; - } - - private static bool IsTypeNameReservedChar(char ch) - { - return ch switch - { - ',' or '[' or ']' or '&' or '*' or '+' or '\\' or '`' or '<' or '>' => true, - _ => false, - }; - } - - private void EscapeName(string name) - { - if (ContainsReservedChar(name)) - { - foreach (char c in name) - { - if (c == '\0') - { - break; - } - - if (char.IsDigit(c)) - { - continue; - } - - if (IsTypeNameReservedChar(c)) - { - continue; - } - - _str.Append(c); - } - } - else - { - Append(name); - } - } - - private void EscapeAssemblyName(string name) - { - Append(name); - } - - private void EscapeEmbeddedAssemblyName(string name) - { - if (name.Contains(']')) - { - foreach (char c in name) - { - if (c == ']') - { - Append('\\'); - } - - Append(c); - } - } - else - { - Append(name); - } - } - - private void PushOpenGenericArgument() - { - _stack.Add(_str.Length); - _stackIdx++; - } - - private void Append(string pStr) - { - int i = pStr.IndexOf('\0'); - if (i < 0) - { - _str.Append(pStr); - } - else if (i > 0) - { - _str.Append(pStr.AsSpan(0, i)); - } - } - - private void Append(char c) - { - _str.Append(c); - } - - internal enum Format - { - ToString, - FullName, - AssemblyQualifiedName, - } - - internal static string? ToString(Type type, Format format) - { - if (format == Format.FullName || format == Format.AssemblyQualifiedName) - { - if (!type.IsGenericTypeDefinition && type.ContainsGenericParameters) - { - return null; - } - } - - var tnb = new TypeNameBuilder(); - tnb.AddAssemblyQualifiedName(type, format); - return tnb.ToString(); - } - - private void AddElementType(Type type) - { - if (!type.HasElementType) - { - return; - } - - AddElementType(type.GetElementType()!); - - if (type.IsPointer) - { - Append('*'); - } - else if (type.IsByRef) - { - Append('&'); - } - else if (type.IsSZArray) - { - Append("[]"); - } - else if (type.IsArray) - { - AddArray(type.GetArrayRank()); - } - } - - internal void AddAssemblyQualifiedName(Type type, Format format) - { - // Append just the type name to the start because - // we don't want to include the fully qualified name - // in the OpenAPI document. - AddName(type.Name); - - // Append generic arguments - if (type.IsGenericType && (!type.IsGenericTypeDefinition || format == Format.ToString)) - { - Type[] genericArguments = type.GetGenericArguments(); - - OpenGenericArguments(); - for (int i = 0; i < genericArguments.Length; i++) - { - Format genericArgumentsFormat = format == Format.FullName ? Format.AssemblyQualifiedName : format; - - OpenGenericArgument(); - AddAssemblyQualifiedName(genericArguments[i], genericArgumentsFormat); - CloseGenericArgument(); - } - CloseGenericArguments(); - } - - // Append pointer, byRef and array qualifiers - AddElementType(type); - - if (format == Format.AssemblyQualifiedName) - { - AddAssemblySpec(type.Module.Assembly.FullName!); - } - } -} diff --git a/src/OpenApi/src/Services/OpenApiComponentService.cs b/src/OpenApi/src/Services/OpenApiComponentService.cs index c9633bac0aec..f43b1b39d045 100644 --- a/src/OpenApi/src/Services/OpenApiComponentService.cs +++ b/src/OpenApi/src/Services/OpenApiComponentService.cs @@ -14,11 +14,11 @@ namespace Microsoft.AspNetCore.OpenApi; /// internal sealed class OpenApiComponentService { - private readonly ConcurrentDictionary _schemas = new() + private readonly ConcurrentDictionary _schemas = new() { // Pre-populate OpenAPI schemas for well-defined types in ASP.NET Core. - [typeof(IFormFile).GetSchemaReferenceId()] = new OpenApiSchema { Type = "string", Format = "binary" }, - [typeof(IFormFileCollection).GetSchemaReferenceId()] = new OpenApiSchema + [typeof(IFormFile)] = new OpenApiSchema { Type = "string", Format = "binary" }, + [typeof(IFormFileCollection)] = new OpenApiSchema { Type = "array", Items = new OpenApiSchema { Type = "string", Format = "binary" } @@ -27,8 +27,7 @@ internal sealed class OpenApiComponentService internal OpenApiSchema GetOrCreateSchema(Type type) { - var schemaId = type.GetSchemaReferenceId(); - return _schemas.GetOrAdd(schemaId, _ => CreateSchema()); + return _schemas.GetOrAdd(type, _ => CreateSchema()); } internal static OpenApiSchema CreateSchema() diff --git a/src/OpenApi/test/Extensions/TypeExtensionsTests.cs b/src/OpenApi/test/Extensions/TypeExtensionsTests.cs deleted file mode 100644 index 58afb2fc10d5..000000000000 --- a/src/OpenApi/test/Extensions/TypeExtensionsTests.cs +++ /dev/null @@ -1,67 +0,0 @@ -// 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.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.OpenApi; - -public class TypeExtensionsTests -{ - private delegate void TestDelegate(int x, int y); - - private class Container - { - internal delegate void ContainedTestDelegate(int x, int y); - } - - private class Outer - { - public class Inner - { - } - public class Inner2 - { - } - } - - private class Outer - { - public class Inner - { - } - } - - public static IEnumerable GetSchemaReferenceId_Data => - [ - [typeof(Todo), "Todo"], - [typeof(IEnumerable), "IEnumerableOfTodo"], - [typeof(TodoWithDueDate), "TodoWithDueDate"], - [typeof(IEnumerable), "IEnumerableOfTodoWithDueDate"], - [(new { Id = 1 }).GetType(), "Int32AnonymousType"], - [(new { Id = 1, Name = "Todo" }).GetType(), "Int32StringAnonymousType"], - [typeof(IFormFile), "IFormFile"], - [typeof(IFormFileCollection), "IFormFileCollection"], - [typeof(Results, Ok>), "ResultsOfOkOfTodoWithDueDateAndOfOkOfTodo"], - [typeof(Ok), "OkOfTodo"], - [typeof(NotFound), "NotFoundOfTodoWithDueDate"], - [typeof(TestDelegate), "TestDelegate"], - [typeof(Container.ContainedTestDelegate), "ContainedTestDelegate"], - [(new int[2, 3]).GetType(), "IntArrayOf2"], - [typeof(List), "ListOfInt32"], - [typeof(List<>), "ListOfT"], - [typeof(List>), "ListOfListOfInt32"], - [typeof(int[]), "Array"], - [typeof(int[,]), "IntArrayOf2"], - [typeof(Outer<>.Inner), "InnerOfT"], - [typeof(Outer.Inner), "InnerOfInt32"], - [typeof(Outer<>.Inner2<>), "InnerOfTAndOfU"], - [typeof(Outer.Inner2), "InnerOfInt32AndOfInt32"], - [typeof(Outer.Inner<>), "InnerOfT"], - [typeof(Outer.Inner), "InnerOfInt32"], - ]; - - [Theory] - [MemberData(nameof(GetSchemaReferenceId_Data))] - public void GetSchemaReferenceId_Works(Type type, string referenceId) - => Assert.Equal(referenceId, type.GetSchemaReferenceId()); -} From 3381e60fc742729044ebcc9fd1896ada0020f9ec Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Thu, 11 Apr 2024 09:06:22 -0700 Subject: [PATCH 4/4] Document and make GetSchema private --- src/OpenApi/src/Services/OpenApiComponentService.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/OpenApi/src/Services/OpenApiComponentService.cs b/src/OpenApi/src/Services/OpenApiComponentService.cs index f43b1b39d045..e949dc6f8236 100644 --- a/src/OpenApi/src/Services/OpenApiComponentService.cs +++ b/src/OpenApi/src/Services/OpenApiComponentService.cs @@ -30,7 +30,8 @@ internal OpenApiSchema GetOrCreateSchema(Type type) return _schemas.GetOrAdd(type, _ => CreateSchema()); } - internal static OpenApiSchema CreateSchema() + // TODO: Implement this method to create a schema for a given type. + private static OpenApiSchema CreateSchema() { return new OpenApiSchema { Type = "string" }; }