Description
(Partially an extension to work happening in #34906)
Right now, when minimal APIs are added to routing they are not constrained with a MatcherPolicy
based on which media types their logic expects requests to format the request body with. This results in an exception being thrown when a request is sent with a body formatted as anything other than "application/json", rather than correctly returning an HTTP status code of 415 HTTP Unsupported Media Type
based on the stated media type in the request's Content-Type
header.
Minimal APIs parameter binding for complex types only supports the "application/json" media type when parsing from the request body, however a minimal API method can accept HttpContext
or HttpRequest
and do its own request body parsing to support whichever format it wishes. In this case, there needs to be a way for the method to control the media types and request body schema that:
- Get reported to API descriptions (e.g. OpenAPI)
- Get used to configure the routing
MatcherPolicy
Note that there is currently no support in ASP.NET Core for defining incoming request body schema via a Type
such that it is populated in ApiExplorer
and subsequently by OpenAPI libraries like Swashbuckle and nSwag. The IApiRequestMetadataProvider
interface only allows configuration of incoming media types and the IApiRequestFormatMetadataProvider
interface is designed to support MVC's "formatters" feature.
Proposal
IApiRequestMetadataProvider
should have a new property added that allows the specification of aType
to represent the request body shape/schema:Type? Type => null;
- ASP.NET Core should add a
ConsumesRequestTypeAttribute
(the incoming equivalent ofProducesResponseTypeAttribute
) that implementsIApiRequestMetadataProvider
. It can describe supporting incoming media types and optionally aType
that represents the incoming request body shape/schema. It might be beneficial to have it derive fromConsumesAttribute
. - Minimal APIs should add a
ConsumesRequestTypeAttribute
instance to the endpoint metadata for methods with complex parameters that are bound from the request body (either implicitly or explicitly via[FromBody]
) - Minimal APIs should be able to add their own
ConsumesRequestTypeAttribute
to their endpoint metadata, either by decorating the method/lambda with theConsumesRequestTypeAttribute
, or via newAccepts(string contentType)
orAccepts<TRequest>(string contentType)
methods onMinimalActionEndpointConventionBuilder
. This should override any consumes metadata that was added via the 1st proposal (i.e. the 1st proposal is likely implemented as fallback logic in the endpoint builder). - Minimal APIs should add a
ConsumesMatcherPolicy
to the route for a mapped method based on the media types declared via theIApiRequestMetadataProvider
instance in the matching endpoint's metadata (noteConsumesRequestTypeAttribute
implementsIApiRequestMetadataProvider
). This will result in a 415 response being sent to requests to that route with an unsupportedContent-Type
media type.
API additions:
namespace Microsoft.AspNetCore.Builder
{
public static class OpenApiEndpointConventionBuilderExtensions
{
+ public static MinimalActionEndpointConventionBuilder Accepts(this MinimalActionEndpointConventionBuilder builder, string contentType, params string[] otherContentTypes);
+ public static MinimalActionEndpointConventionBuilder Accepts<TRequest>(this MinimalActionEndpointConventionBuilder builder, string? contentType = null, params string[] otherContentTypes);
+ public static MinimalActionEndpointConventionBuilder Accepts(this MinimalActionEndpointConventionBuilder builder, Type requestType, string? contentType = null, params string[] otherContentTypes);
}
}
namespace Microsoft.AspNetCore.Http
{
public static partial class RequestDelegateFactory
{
+ public static RequestDelegateResult Create(Delegate action, RequestDelegateFactoryOptions? options = null);
+ public static RequestDelegateResult Create(MethodInfo methodInfo, Func<HttpContext, object>? targetFactory = null, RequestDelegateFactoryOptions? options = null);
}
}
+namespace Microsoft.AspNetCore.Http
+{
+ /// <summary>
+ /// The result of creating a <see cref="RequestDelegate" /> from a <see cref="Delegate" />
+ /// </summary>
+ public sealed class RequestDelegateResult
+ {
+ /// <summary>
+ /// Creates a new instance of <see cref="RequestDelegateResult"/>.
+ /// </summary>
+ public RequestDelegateResult(RequestDelegate requestDelegate, IReadOnlyList<object> metadata);
+ /// <summary>
+ /// Gets the <see cref="RequestDelegate" />
+ /// </summary>
+ /// <returns>A task that represents the completion of request processing.</returns>
+ public RequestDelegate RequestDelegate { get; init; }
+ /// <summary>
+ /// Gets endpoint metadata inferred from creating the <see cref="RequestDelegate" />
+ /// </summary>
+ public IReadOnlyList<object> EndpointMetadata { get; init; }
+ }
+ }
+namespace Microsoft.AspNetCore.Http.Metadata
+{
+ /// <summary>
+ /// Metadata that specifies the supported request content types.
+ /// </summary>
+ public sealed class AcceptsMetadata : IAcceptsMetadata
+ {
+ /// <summary>
+ /// Creates a new instance of <see cref="AcceptsMetadata"/>.
+ /// </summary>
+ public AcceptsMetadata(string[] contentTypes);
+ /// <summary>
+ /// Creates a new instance of <see cref="AcceptsMetadata"/> with a type.
+ /// </summary>
+ public AcceptsMetadata(Type? type, string[] contentTypes);
+ /// <summary>
+ /// Gets the supported request content types.
+ /// </summary>
+ public IReadOnlyList<string> ContentTypes { get; }
+ /// <summary>
+ /// Accepts request content types of any shape.
+ /// </summary>
+ public Type? RequestType { get; }
+ }
+}
+namespace Microsoft.AspNetCore.Http.Metadata
+{
+ /// <summary>
+ /// Interface for accepting request media types.
+ /// </summary>
+ public interface IAcceptsMetadata
+ {
+ /// <summary>
+ /// Gets a list of request content types.
+ /// </summary>
+ IReadOnlyList<string> ContentTypes { get; }
+ /// <summary>
+ /// Accepts request content types of any shape.
+ /// </summary>
+ Type? RequestType { get; }
+ }
+}
Accepts Extension method Usage
app.MapPost("/todos/xmlorjson", async (HttpRequest request, TodoDb db) =>
{
string contentType = request.Headers.ContentType;
var todo = contentType switch
{
"application/json" => await request.Body.ReadAsJsonAsync<Todo>(),
"application/xml" => await request.Body.ReadAsXmlAsync<Todo>(request.ContentLength),
_ => null,
};
if (todo is null)
{
return Results.StatusCode(StatusCodes.Status415UnsupportedMediaType);
}
if (!MinimalValidation.TryValidate(todo, out var errors))
return Results.ValidationProblem(errors);
db.Todos.Add(todo);
await db.SaveChangesAsync();
return AppResults.Created(todo, contentType);
})
.WithName("AddTodoXmlOrJson")
.WithTags("TodoApi")
.Accepts<Todo>("application/json", "application/xml")
.Produces(StatusCodes.Status415UnsupportedMediaType)
.ProducesValidationProblem()
.Produces<Todo>(StatusCodes.Status201Created, "application/json", "application/xml");
Request DelegateResult Usage
var requestDelegateResult = RequestDelegateFactory.Create(action, options);
var builder = new RouteEndpointBuilder(
requestDelegateResult.RequestDelegate,
pattern,
defaultOrder)
{
DisplayName = pattern.RawText ?? pattern.DebuggerToString(),
};
//Add add request delegate metadata
foreach(var metadata in requestDelegateResult.EndpointMetadata)
{
builder.Metadata.Add(metadata);
}
AcceptsMetadata Usage Example
builder.WithMetadata(new AcceptsMetadata(requestType, GetAllContentTypes(contentType, additionalContentTypes)));