-
Notifications
You must be signed in to change notification settings - Fork 10.5k
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
IApiRequestMetadataProvidershould have a new property added that allows the specification of aTypeto 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 aTypethat represents the incoming request body shape/schema. It might be beneficial to have it derive fromConsumesAttribute. - Minimal APIs should add a
ConsumesRequestTypeAttributeinstance 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
ConsumesRequestTypeAttributeto 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
ConsumesMatcherPolicyto the route for a mapped method based on the media types declared via theIApiRequestMetadataProviderinstance in the matching endpoint's metadata (noteConsumesRequestTypeAttributeimplementsIApiRequestMetadataProvider). This will result in a 415 response being sent to requests to that route with an unsupportedContent-Typemedia 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)));