diff --git a/src/Http/Http.Abstractions/src/Metadata/IAcceptsMetadata.cs b/src/Http/Http.Abstractions/src/Metadata/IAcceptsMetadata.cs index a3325158a7ac..ce20bb9fdce5 100644 --- a/src/Http/Http.Abstractions/src/Metadata/IAcceptsMetadata.cs +++ b/src/Http/Http.Abstractions/src/Metadata/IAcceptsMetadata.cs @@ -21,5 +21,10 @@ public interface IAcceptsMetadata /// Gets the type being read from the request. /// Type? RequestType { get; } + + /// + /// Gets a value that determines if the request body is optional. + /// + bool IsOptional { get; } } } diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index 86257d4625ca..b0d505496ade 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -10,6 +10,7 @@ Microsoft.AspNetCore.Http.IResult Microsoft.AspNetCore.Http.IResult.ExecuteAsync(Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Http.Metadata.IAcceptsMetadata Microsoft.AspNetCore.Http.Metadata.IAcceptsMetadata.ContentTypes.get -> System.Collections.Generic.IReadOnlyList! +Microsoft.AspNetCore.Http.Metadata.IAcceptsMetadata.IsOptional.get -> bool Microsoft.AspNetCore.Http.Metadata.IAcceptsMetadata.RequestType.get -> System.Type? Microsoft.AspNetCore.Http.Metadata.IFromBodyMetadata Microsoft.AspNetCore.Http.Metadata.IFromBodyMetadata.AllowEmpty.get -> bool diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs index ff3bde84039a..8d6c4f3389c1 100644 --- a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs +++ b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs @@ -62,8 +62,7 @@ public static partial class RequestDelegateFactory private static ParameterExpression TempSourceStringExpr => TryParseMethodCache.TempSourceStringExpr; private static readonly BinaryExpression TempSourceStringNotNullExpr = Expression.NotEqual(TempSourceStringExpr, Expression.Constant(null)); private static readonly BinaryExpression TempSourceStringNullExpr = Expression.Equal(TempSourceStringExpr, Expression.Constant(null)); - - private static readonly AcceptsMetadata DefaultAcceptsMetadata = new(new[] { "application/json" }); + private static readonly string[] DefaultAcceptsContentType = new[] { "application/json" }; /// /// Creates a implementation for . @@ -879,11 +878,11 @@ private static Expression BindParameterFromBody(ParameterInfo parameter, bool al } } - factoryContext.Metadata.Add(DefaultAcceptsMetadata); var isOptional = IsOptionalParameter(parameter, factoryContext); factoryContext.JsonRequestBodyType = parameter.ParameterType; factoryContext.AllowEmptyRequestBody = allowEmpty || isOptional; + factoryContext.Metadata.Add(new AcceptsMetadata(parameter.ParameterType, factoryContext.AllowEmptyRequestBody, DefaultAcceptsContentType)); if (!factoryContext.AllowEmptyRequestBody) { diff --git a/src/Http/Routing/test/UnitTests/Builder/DelegateEndpointRouteBuilderExtensionsTest.cs b/src/Http/Routing/test/UnitTests/Builder/DelegateEndpointRouteBuilderExtensionsTest.cs index 1d222785a013..49e92ad1f0e0 100644 --- a/src/Http/Routing/test/UnitTests/Builder/DelegateEndpointRouteBuilderExtensionsTest.cs +++ b/src/Http/Routing/test/UnitTests/Builder/DelegateEndpointRouteBuilderExtensionsTest.cs @@ -290,14 +290,13 @@ public void MapPost_BuildsEndpointWithCorrectEndpointMetadata() // Trigger Endpoint build by calling getter. var endpoint = Assert.Single(dataSource.Endpoints); - var endpointMetadata = endpoint.Metadata.GetOrderedMetadata(); + var endpointMetadata = endpoint.Metadata.GetMetadata(); + Assert.NotNull(endpointMetadata); - Assert.Equal(2, endpointMetadata.Count); + Assert.False(endpointMetadata!.IsOptional); + Assert.Equal(typeof(Todo), endpointMetadata.RequestType); + Assert.Equal(new[] { "application/xml" }, endpointMetadata.ContentTypes); - var lastAddedMetadata = endpointMetadata[^1]; - - Assert.Equal(typeof(Todo), lastAddedMetadata.RequestType); - Assert.Equal(new[] { "application/xml" }, lastAddedMetadata.ContentTypes); } [Fact] @@ -567,13 +566,13 @@ public TestConsumesAttribute(Type requestType, string contentType, params string } IReadOnlyList IAcceptsMetadata.ContentTypes => _contentTypes; - Type? IAcceptsMetadata.RequestType => _requestType; + bool IAcceptsMetadata.IsOptional => false; + Type? _requestType; List _contentTypes = new(); - } class Todo diff --git a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs index 03c86fe3aacf..b3cf26a7d728 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs @@ -102,8 +102,6 @@ private ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, string }, }; - var hasJsonBody = false; - foreach (var parameter in methodInfo.GetParameters()) { var parameterDescription = CreateApiParameterDescription(parameter, routeEndpoint.RoutePattern); @@ -113,33 +111,37 @@ private ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, string continue; } - if (parameterDescription.Source == BindingSource.Body) - { - hasJsonBody = true; - } - apiDescription.ParameterDescriptions.Add(parameterDescription); } - // Get custom attributes for the handler. ConsumesAttribute is one of the examples. - var acceptsRequestType = routeEndpoint.Metadata.GetMetadata()?.RequestType; - if (acceptsRequestType is not null) + // Get IAcceptsMetadata. + var acceptsMetadata = routeEndpoint.Metadata.GetMetadata(); + if (acceptsMetadata is not null) { + var acceptsRequestType = acceptsMetadata.RequestType; + var isOptional = acceptsMetadata.IsOptional; var parameterDescription = new ApiParameterDescription { - Name = acceptsRequestType.Name, - ModelMetadata = CreateModelMetadata(acceptsRequestType), + Name = acceptsRequestType is not null ? acceptsRequestType.Name : typeof(void).Name, + ModelMetadata = CreateModelMetadata(acceptsRequestType ?? typeof(void)), Source = BindingSource.Body, - Type = acceptsRequestType, - IsRequired = true, + Type = acceptsRequestType ?? typeof(void), + IsRequired = !isOptional, }; - apiDescription.ParameterDescriptions.Add(parameterDescription); + + var supportedRequestFormats = apiDescription.SupportedRequestFormats; + + foreach (var contentType in acceptsMetadata.ContentTypes) + { + supportedRequestFormats.Add(new ApiRequestFormat + { + MediaType = contentType + }); + } } - AddSupportedRequestFormats(apiDescription.SupportedRequestFormats, hasJsonBody, routeEndpoint.Metadata); AddSupportedResponseTypes(apiDescription.SupportedResponseTypes, methodInfo.ReturnType, routeEndpoint.Metadata); - AddActionDescriptorEndpointMetadata(apiDescription.ActionDescriptor, routeEndpoint.Metadata); return apiDescription; @@ -150,7 +152,8 @@ private ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, string var (source, name, allowEmpty) = GetBindingSourceAndName(parameter, pattern); // Services are ignored because they are not request parameters. - if (source == BindingSource.Services) + // We ignore/skip body parameter because the value will be retrieved from the IAcceptsMetadata. + if (source == BindingSource.Services || source == BindingSource.Body) { return null; } @@ -222,33 +225,6 @@ private ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, string } } - private static void AddSupportedRequestFormats( - IList supportedRequestFormats, - bool hasJsonBody, - EndpointMetadataCollection endpointMetadata) - { - var requestMetadata = endpointMetadata.GetOrderedMetadata(); - var declaredContentTypes = DefaultApiDescriptionProvider.GetDeclaredContentTypes(requestMetadata); - - if (declaredContentTypes.Count > 0) - { - foreach (var contentType in declaredContentTypes) - { - supportedRequestFormats.Add(new ApiRequestFormat - { - MediaType = contentType, - }); - } - } - else if (hasJsonBody) - { - supportedRequestFormats.Add(new ApiRequestFormat - { - MediaType = "application/json", - }); - } - } - private static void AddSupportedResponseTypes( IList supportedResponseTypes, Type returnType, diff --git a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs index ea6a51351f72..12179ee8bfd8 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs @@ -51,23 +51,6 @@ public void UsesApplicationNameAsControllerNameIfNoDeclaringType() Assert.Equal(nameof(EndpointMetadataApiDescriptionProviderTest), apiDescription.ActionDescriptor.RouteValues["controller"]); } - [Fact] - public void AddsJsonRequestFormatWhenFromBodyInferred() - { - static void AssertJsonRequestFormat(ApiDescription apiDescription) - { - var requestFormat = Assert.Single(apiDescription.SupportedRequestFormats); - Assert.Equal("application/json", requestFormat.MediaType); - Assert.Null(requestFormat.Formatter); - } - - AssertJsonRequestFormat(GetApiDescription( - (InferredJsonClass fromBody) => { })); - - AssertJsonRequestFormat(GetApiDescription( - ([FromBody] int fromBody) => { })); - } - [Fact] public void AddsRequestFormatFromMetadata() { @@ -109,11 +92,27 @@ public void AddsMultipleRequestFormatsFromMetadata() } [Fact] - public void AddsMultipleRequestFormatsFromMetadataWithRequestType() + public void AddsMultipleRequestFormatsFromMetadataWithRequestTypeAndOptionalBodyParameter() + { + var apiDescription = GetApiDescription( + [Consumes(typeof(InferredJsonClass), "application/custom0", "application/custom1", IsOptional = true)] + () => + { }); + + Assert.Equal(2, apiDescription.SupportedRequestFormats.Count); + + var apiParameterDescription = apiDescription.ParameterDescriptions[0]; + Assert.Equal("InferredJsonClass", apiParameterDescription.Type.Name); + Assert.False(apiParameterDescription.IsRequired); + } + + [Fact] + public void AddsMultipleRequestFormatsFromMetadataWithRequiredBodyParameter() { var apiDescription = GetApiDescription( - [Consumes(typeof(InferredJsonClass), "application/custom0", "application/custom1")] - () => { }); + [Consumes(typeof(InferredJsonClass), "application/custom0", "application/custom1", IsOptional = false)] + (InferredJsonClass fromBody) => + { }); Assert.Equal(2, apiDescription.SupportedRequestFormats.Count); @@ -318,14 +317,11 @@ public void DoesNotAddFromServiceParameterAsService() } [Fact] - public void AddsFromBodyParameterAsBody() + public void DoesNotAddFromBodyParameterInTheParameterDescription() { static void AssertBodyParameter(ApiDescription apiDescription, Type expectedType) { - var param = Assert.Single(apiDescription.ParameterDescriptions); - Assert.Equal(expectedType, param.Type); - Assert.Equal(expectedType, param.ModelMetadata.ModelType); - Assert.Equal(BindingSource.Body, param.Source); + Assert.Empty(apiDescription.ParameterDescriptions); } AssertBodyParameter(GetApiDescription((InferredJsonClass foo) => { }), typeof(InferredJsonClass)); @@ -345,7 +341,7 @@ public void AddsDefaultValueFromParameters() public void AddsMultipleParameters() { var apiDescription = GetApiDescription(([FromRoute] int foo, int bar, InferredJsonClass fromBody) => { }); - Assert.Equal(3, apiDescription.ParameterDescriptions.Count); + Assert.Equal(2, apiDescription.ParameterDescriptions.Count); var fooParam = apiDescription.ParameterDescriptions[0]; Assert.Equal(typeof(int), fooParam.Type); @@ -358,12 +354,6 @@ public void AddsMultipleParameters() Assert.Equal(typeof(int), barParam.ModelMetadata.ModelType); Assert.Equal(BindingSource.Query, barParam.Source); Assert.True(barParam.IsRequired); - - var fromBodyParam = apiDescription.ParameterDescriptions[2]; - Assert.Equal(typeof(InferredJsonClass), fromBodyParam.Type); - Assert.Equal(typeof(InferredJsonClass), fromBodyParam.ModelMetadata.ModelType); - Assert.Equal(BindingSource.Body, fromBodyParam.Source); - Assert.False(fromBodyParam.IsRequired); // Reference type in oblivious nullability context } [Fact] @@ -385,34 +375,6 @@ public void TestParameterIsRequired() Assert.False(barParam.IsRequired); } -#nullable enable - - [Fact] - public void TestIsRequiredFromBody() - { - var apiDescription0 = GetApiDescription(([FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] InferredJsonClass fromBody) => { }); - var apiDescription1 = GetApiDescription((InferredJsonClass? fromBody) => { }); - Assert.Equal(1, apiDescription0.ParameterDescriptions.Count); - Assert.Equal(1, apiDescription1.ParameterDescriptions.Count); - - var fromBodyParam0 = apiDescription0.ParameterDescriptions[0]; - Assert.Equal(typeof(InferredJsonClass), fromBodyParam0.Type); - Assert.Equal(typeof(InferredJsonClass), fromBodyParam0.ModelMetadata.ModelType); - Assert.Equal(BindingSource.Body, fromBodyParam0.Source); - Assert.False(fromBodyParam0.IsRequired); - - var fromBodyParam1 = apiDescription1.ParameterDescriptions[0]; - Assert.Equal(typeof(InferredJsonClass), fromBodyParam1.Type); - Assert.Equal(typeof(InferredJsonClass), fromBodyParam1.ModelMetadata.ModelType); - Assert.Equal(BindingSource.Body, fromBodyParam1.Source); - Assert.False(fromBodyParam1.IsRequired); - } - - // This is necessary for TestIsRequiredFromBody to pass until https://github.com/dotnet/roslyn/issues/55254 is resolved. - private object RandomMethod() => throw new NotImplementedException(); - -#nullable disable - [Fact] public void AddsDisplayNameFromRouteEndpoint() { @@ -654,6 +616,148 @@ public void HandleAcceptsMetadata() }); } + [Fact] + public void HandleAcceptsMetadataWithTypeParameter() + { + // Arrange + var builder = new TestEndpointRouteBuilder(new ApplicationBuilder(null)); + builder.MapPost("/api/todos", (InferredJsonClass inferredJsonClass) => "") + .Accepts(typeof(InferredJsonClass), "application/json"); + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var hostEnvironment = new HostEnvironment + { + ApplicationName = nameof(EndpointMetadataApiDescriptionProviderTest) + }; + var provider = new EndpointMetadataApiDescriptionProvider(endpointDataSource, hostEnvironment, new ServiceProviderIsService()); + + // Act + provider.OnProvidersExecuting(context); + provider.OnProvidersExecuted(context); + + // Assert + var parameterDescriptions = context.Results.SelectMany(r => r.ParameterDescriptions); + var bodyParameterDescription = parameterDescriptions.Single(); + Assert.Equal(typeof(InferredJsonClass), bodyParameterDescription.Type); + Assert.Equal(typeof(InferredJsonClass).Name, bodyParameterDescription.Name); + Assert.True(bodyParameterDescription.IsRequired); + } + +#nullable enable + + [Fact] + public void HandleDefaultIAcceptsMetadataForRequiredBodyParameter() + { + // Arrange + var services = new ServiceCollection(); + var serviceProvider = services.BuildServiceProvider(); + var builder = new TestEndpointRouteBuilder(new ApplicationBuilder(serviceProvider)); + builder.MapPost("/api/todos", (InferredJsonClass inferredJsonClass) => ""); + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var hostEnvironment = new HostEnvironment + { + ApplicationName = nameof(EndpointMetadataApiDescriptionProviderTest) + }; + var provider = new EndpointMetadataApiDescriptionProvider(endpointDataSource, hostEnvironment, new ServiceProviderIsService()); + + // Act + provider.OnProvidersExecuting(context); + provider.OnProvidersExecuted(context); + + // Assert + var parameterDescriptions = context.Results.SelectMany(r => r.ParameterDescriptions); + var bodyParameterDescription = parameterDescriptions.Single(); + Assert.Equal(typeof(InferredJsonClass), bodyParameterDescription.Type); + Assert.Equal(typeof(InferredJsonClass).Name, bodyParameterDescription.Name); + Assert.True(bodyParameterDescription.IsRequired); + + // Assert + var requestFormats = context.Results.SelectMany(r => r.SupportedRequestFormats); + var defaultRequestFormat = requestFormats.Single(); + Assert.Equal("application/json", defaultRequestFormat.MediaType); + } + +#nullable restore + +#nullable enable + + [Fact] + public void HandleDefaultIAcceptsMetadataForOptionalBodyParameter() + { + // Arrange + var services = new ServiceCollection(); + var serviceProvider = services.BuildServiceProvider(); + var builder = new TestEndpointRouteBuilder(new ApplicationBuilder(serviceProvider)); + builder.MapPost("/api/todos", (InferredJsonClass? inferredJsonClass) => ""); + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var hostEnvironment = new HostEnvironment + { + ApplicationName = nameof(EndpointMetadataApiDescriptionProviderTest) + }; + var provider = new EndpointMetadataApiDescriptionProvider(endpointDataSource, hostEnvironment, new ServiceProviderIsService()); + + // Act + provider.OnProvidersExecuting(context); + provider.OnProvidersExecuted(context); + + // Assert + var parameterDescriptions = context.Results.SelectMany(r => r.ParameterDescriptions); + var bodyParameterDescription = parameterDescriptions.Single(); + Assert.Equal(typeof(InferredJsonClass), bodyParameterDescription.Type); + Assert.Equal(typeof(InferredJsonClass).Name, bodyParameterDescription.Name); + Assert.False(bodyParameterDescription.IsRequired); + + // Assert + var requestFormats = context.Results.SelectMany(r => r.SupportedRequestFormats); + var defaultRequestFormat = requestFormats.Single(); + Assert.Equal("application/json", defaultRequestFormat.MediaType); + } + +#nullable restore + +#nullable enable + + [Fact] + public void HandleIAcceptsMetadataWithConsumesAttributeAndInferredOptionalFromBodyType() + { + // Arrange + var services = new ServiceCollection(); + var serviceProvider = services.BuildServiceProvider(); + var builder = new TestEndpointRouteBuilder(new ApplicationBuilder(serviceProvider)); + builder.MapPost("/api/todos", [Consumes("application/xml")] (InferredJsonClass? inferredJsonClass) => ""); + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var hostEnvironment = new HostEnvironment + { + ApplicationName = nameof(EndpointMetadataApiDescriptionProviderTest) + }; + var provider = new EndpointMetadataApiDescriptionProvider(endpointDataSource, hostEnvironment, new ServiceProviderIsService()); + + // Act + provider.OnProvidersExecuting(context); + provider.OnProvidersExecuted(context); + + // Assert + var parameterDescriptions = context.Results.SelectMany(r => r.ParameterDescriptions); + var bodyParameterDescription = parameterDescriptions.Single(); + Assert.Equal(typeof(void), bodyParameterDescription.Type); + Assert.Equal(typeof(void).Name, bodyParameterDescription.Name); + Assert.True(bodyParameterDescription.IsRequired); + + // Assert + var requestFormats = context.Results.SelectMany(r => r.SupportedRequestFormats); + var defaultRequestFormat = requestFormats.Single(); + Assert.Equal("application/xml", defaultRequestFormat.MediaType); + } + +#nullable restore + private static IEnumerable GetSortedMediaTypes(ApiResponseType apiResponseType) { return apiResponseType.ApiResponseFormats diff --git a/src/Mvc/Mvc.Core/src/Builder/OpenApiEndpointConventionBuilderExtensions.cs b/src/Mvc/Mvc.Core/src/Builder/OpenApiEndpointConventionBuilderExtensions.cs index 08607ce5bd22..a233aa8c8a51 100644 --- a/src/Mvc/Mvc.Core/src/Builder/OpenApiEndpointConventionBuilderExtensions.cs +++ b/src/Mvc/Mvc.Core/src/Builder/OpenApiEndpointConventionBuilderExtensions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; @@ -122,16 +123,16 @@ public static DelegateEndpointConventionBuilder ProducesValidationProblem(this D } /// - /// Adds the to for all builders + /// Adds to for all builders /// produced by . /// - /// The type of the request. + /// The type of the request body. /// The . - /// The request content type. Defaults to "application/json" if empty. - /// Additional response content types the endpoint produces for the supplied status code. + /// The request content type that the endpoint accepts. + /// The list of additional request content types that the endpoint accepts. /// A that can be used to further customize the endpoint. public static DelegateEndpointConventionBuilder Accepts(this DelegateEndpointConventionBuilder builder, - string contentType, params string[] additionalContentTypes) + string contentType, params string[] additionalContentTypes) where TRequest : notnull { Accepts(builder, typeof(TRequest), contentType, additionalContentTypes); @@ -139,19 +140,68 @@ public static DelegateEndpointConventionBuilder Accepts(this DelegateE } /// - /// Adds the to for all builders + /// Adds to for all builders /// produced by . /// + /// The type of the request body. /// The . - /// The type of the request. Defaults to null. - /// The response content type that the endpoint accepts. - /// Additional response content types the endpoint accepts + /// Sets a value that determines if the request body is optional. + /// The request content type that the endpoint accepts. + /// The list of additional request content types that the endpoint accepts. + /// A that can be used to further customize the endpoint. + public static DelegateEndpointConventionBuilder Accepts(this DelegateEndpointConventionBuilder builder, + bool isOptional, string contentType, params string[] additionalContentTypes) where TRequest : notnull + { + Accepts(builder, typeof(TRequest), isOptional, contentType, additionalContentTypes); + + return builder; + } + + /// + /// Adds to for all builders + /// produced by . + /// + /// The . + /// The type of the request body. + /// The request content type that the endpoint accepts. + /// The list of additional request content types that the endpoint accepts. /// A that can be used to further customize the endpoint. public static DelegateEndpointConventionBuilder Accepts(this DelegateEndpointConventionBuilder builder, Type requestType, string contentType, params string[] additionalContentTypes) { - builder.WithMetadata(new ConsumesAttribute(requestType, contentType, additionalContentTypes)); + builder.WithMetadata(new AcceptsMetadata(requestType, false, GetAllContentTypes(contentType, additionalContentTypes))); + return builder; + } + + + /// + /// Adds to for all builders + /// produced by . + /// + /// The . + /// The type of the request body. + /// Sets a value that determines if the request body is optional. + /// The request content type that the endpoint accepts. + /// The list of additional request content types that the endpoint accepts. + /// A that can be used to further customize the endpoint. + public static DelegateEndpointConventionBuilder Accepts(this DelegateEndpointConventionBuilder builder, + Type requestType, bool isOptional, string contentType, params string[] additionalContentTypes) + { + builder.WithMetadata(new AcceptsMetadata(requestType, isOptional, GetAllContentTypes(contentType, additionalContentTypes))); return builder; } + + private static string[] GetAllContentTypes(string contentType, string[] additionalContentTypes) + { + var allContentTypes = new string[additionalContentTypes.Length + 1]; + allContentTypes[0] = contentType; + + for (var i = 0; i < additionalContentTypes.Length; i++) + { + allContentTypes[i + 1] = additionalContentTypes[i]; + } + + return allContentTypes; + } } } diff --git a/src/Mvc/Mvc.Core/src/ConsumesAttribute.cs b/src/Mvc/Mvc.Core/src/ConsumesAttribute.cs index 817e54a724d5..f7ba8e772279 100644 --- a/src/Mvc/Mvc.Core/src/ConsumesAttribute.cs +++ b/src/Mvc/Mvc.Core/src/ConsumesAttribute.cs @@ -35,8 +35,8 @@ public class ConsumesAttribute : /// /// Creates a new instance of . - /// The request content type - /// The additional list of allowed request content types + /// The request content type. + /// The additional list of allowed request content types. /// public ConsumesAttribute(string contentType, params string[] otherContentTypes) { @@ -55,13 +55,14 @@ public ConsumesAttribute(string contentType, params string[] otherContentTypes) } ContentTypes = GetContentTypes(contentType, otherContentTypes); + _contentTypes = GetAllContentTypes(contentType, otherContentTypes); } /// /// Creates a new instance of . - /// The type being read from the request - /// The request content type - /// The additional list of allowed request content types + /// The type being read from the request. + /// The request content type. + /// The additional list of allowed request content types. /// public ConsumesAttribute(Type requestType, string contentType, params string[] otherContentTypes) { @@ -96,6 +97,12 @@ public ConsumesAttribute(Type requestType, string contentType, params string[] o /// public MediaTypeCollection ContentTypes { get; set; } + /// + /// Gets or sets a value that determines if the request body is optional. + /// This value is only used to specify if the request body is required in API explorer. + /// + public bool IsOptional { get; set; } + readonly Type? _requestType; readonly List _contentTypes = new(); diff --git a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt index 80e9d26d26a6..bd730b121109 100644 --- a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt +++ b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt @@ -529,6 +529,8 @@ *REMOVED*~virtual Microsoft.AspNetCore.Mvc.Routing.UrlHelperBase.IsLocalUrl(string url) -> bool *REMOVED*~virtual Microsoft.AspNetCore.Mvc.Routing.UrlHelperBase.Link(string routeName, object values) -> string Microsoft.AspNetCore.Mvc.ConsumesAttribute.ConsumesAttribute(System.Type! requestType, string! contentType, params string![]! otherContentTypes) -> void +Microsoft.AspNetCore.Mvc.ConsumesAttribute.IsOptional.get -> bool +Microsoft.AspNetCore.Mvc.ConsumesAttribute.IsOptional.set -> void Microsoft.AspNetCore.Mvc.JsonOptions.AllowInputFormatterExceptionMessages.get -> bool Microsoft.AspNetCore.Mvc.JsonOptions.AllowInputFormatterExceptionMessages.set -> void Microsoft.AspNetCore.Mvc.Controllers.ControllerActivatorProvider.CreateAsyncReleaser(Microsoft.AspNetCore.Mvc.Controllers.ControllerActionDescriptor! descriptor) -> System.Func? @@ -545,7 +547,9 @@ Microsoft.AspNetCore.Mvc.Infrastructure.ActionDescriptorCollection.Items.get -> Microsoft.AspNetCore.Mvc.Infrastructure.AmbiguousActionException.AmbiguousActionException(System.Runtime.Serialization.SerializationInfo! info, System.Runtime.Serialization.StreamingContext context) -> void Microsoft.AspNetCore.Mvc.Infrastructure.AmbiguousActionException.AmbiguousActionException(string? message) -> void Microsoft.AspNetCore.Mvc.Infrastructure.ContentResultExecutor.ContentResultExecutor(Microsoft.Extensions.Logging.ILogger! logger, Microsoft.AspNetCore.Mvc.Infrastructure.IHttpResponseStreamWriterFactory! httpResponseStreamWriterFactory) -> void +static Microsoft.AspNetCore.Http.OpenApiEndpointConventionBuilderExtensions.Accepts(this Microsoft.AspNetCore.Builder.DelegateEndpointConventionBuilder! builder, System.Type! requestType, bool isOptional, string! contentType, params string![]! additionalContentTypes) -> Microsoft.AspNetCore.Builder.DelegateEndpointConventionBuilder! static Microsoft.AspNetCore.Http.OpenApiEndpointConventionBuilderExtensions.Accepts(this Microsoft.AspNetCore.Builder.DelegateEndpointConventionBuilder! builder, System.Type! requestType, string! contentType, params string![]! additionalContentTypes) -> Microsoft.AspNetCore.Builder.DelegateEndpointConventionBuilder! +static Microsoft.AspNetCore.Http.OpenApiEndpointConventionBuilderExtensions.Accepts(this Microsoft.AspNetCore.Builder.DelegateEndpointConventionBuilder! builder, bool isOptional, string! contentType, params string![]! additionalContentTypes) -> Microsoft.AspNetCore.Builder.DelegateEndpointConventionBuilder! static Microsoft.AspNetCore.Http.OpenApiEndpointConventionBuilderExtensions.Accepts(this Microsoft.AspNetCore.Builder.DelegateEndpointConventionBuilder! builder, string! contentType, params string![]! additionalContentTypes) -> Microsoft.AspNetCore.Builder.DelegateEndpointConventionBuilder! static Microsoft.AspNetCore.Http.OpenApiEndpointConventionBuilderExtensions.ExcludeFromDescription(this Microsoft.AspNetCore.Builder.DelegateEndpointConventionBuilder! builder) -> Microsoft.AspNetCore.Builder.DelegateEndpointConventionBuilder! static Microsoft.AspNetCore.Http.OpenApiEndpointConventionBuilderExtensions.Produces(this Microsoft.AspNetCore.Builder.DelegateEndpointConventionBuilder! builder, int statusCode, System.Type? responseType = null, string? contentType = null, params string![]! additionalContentTypes) -> Microsoft.AspNetCore.Builder.DelegateEndpointConventionBuilder! diff --git a/src/Shared/RoutingMetadata/AcceptsMetadata.cs b/src/Shared/RoutingMetadata/AcceptsMetadata.cs index eadfd5deb565..3763978a7de7 100644 --- a/src/Shared/RoutingMetadata/AcceptsMetadata.cs +++ b/src/Shared/RoutingMetadata/AcceptsMetadata.cs @@ -29,7 +29,7 @@ public AcceptsMetadata(string[] contentTypes) /// /// Creates a new instance of with a type. /// - public AcceptsMetadata(Type? type, string[] contentTypes) + public AcceptsMetadata(Type? type, bool isOptional, string[] contentTypes) { RequestType = type ?? throw new ArgumentNullException(nameof(type)); @@ -39,6 +39,7 @@ public AcceptsMetadata(Type? type, string[] contentTypes) } ContentTypes = contentTypes; + IsOptional = isOptional; } /// @@ -47,8 +48,13 @@ public AcceptsMetadata(Type? type, string[] contentTypes) public IReadOnlyList ContentTypes { get; } /// - /// Accepts request content types of any shape. + /// Gets the type being read from the request. /// public Type? RequestType { get; } + + /// + /// Gets a value that determines if the request body is optional. + /// + public bool IsOptional { get; } } }