Skip to content

Improve Minimal APIs support for request media types & body schema/shape #35082

Closed
@DamianEdwards

Description

@DamianEdwards

(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:

  1. Get reported to API descriptions (e.g. OpenAPI)
  2. 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

  1. IApiRequestMetadataProvider should have a new property added that allows the specification of a Type to represent the request body shape/schema: Type? Type => null;
  2. ASP.NET Core should add a ConsumesRequestTypeAttribute (the incoming equivalent of ProducesResponseTypeAttribute) that implements IApiRequestMetadataProvider. It can describe supporting incoming media types and optionally a Type that represents the incoming request body shape/schema. It might be beneficial to have it derive from ConsumesAttribute.
  3. 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])
  4. Minimal APIs should be able to add their own ConsumesRequestTypeAttribute to their endpoint metadata, either by decorating the method/lambda with the ConsumesRequestTypeAttribute, or via new Accepts(string contentType) or Accepts<TRequest>(string contentType) methods on MinimalActionEndpointConventionBuilder. 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).
  5. Minimal APIs should add a ConsumesMatcherPolicy to the route for a mapped method based on the media types declared via the IApiRequestMetadataProvider instance in the matching endpoint's metadata (note ConsumesRequestTypeAttribute implements IApiRequestMetadataProvider). This will result in a 415 response being sent to requests to that route with an unsupported Content-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)));

Metadata

Metadata

Labels

api-approvedAPI was approved in API review, it can be implementedarea-minimalIncludes minimal APIs, endpoint filters, parameter binding, request delegate generator etcarea-mvcIncludes: MVC, Actions and Controllers, Localization, CORS, most templatesfeature-minimal-actionsController-like actions for endpoint routing

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions