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/Services/OpenApiComponentService.cs b/src/OpenApi/src/Services/OpenApiComponentService.cs index f327ee1c34b3..e949dc6f8236 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)] = new OpenApiSchema { Type = "string", Format = "binary" }, + [typeof(IFormFileCollection)] = new OpenApiSchema + { + Type = "array", + Items = new OpenApiSchema { Type = "string", Format = "binary" } + }, + }; + + internal OpenApiSchema GetOrCreateSchema(Type type) + { + return _schemas.GetOrAdd(type, _ => CreateSchema()); + } + + // TODO: Implement this method to create a schema for a given type. + private 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/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); }