Skip to content

Allow parameter and return types of route handler delegates (Minimal APIs) to contribute to endpoint metadata #40646

Closed
@DamianEdwards

Description

@DamianEdwards

Overview

Introduce the capability for types used for parameters and return values for route handler delegates (Minimal APIs) to contribute to metadata of the endpoint they're mapped to. This will allow types that implement custom binding logic via TryParse or BindAsync, and IResult types, to add metadata that describes the API parameters and responses in Swagger UI and OpenAPI documents.

This, along with some other capabilities, will better allow route handler delegates to self-describe themselves from just the type information in their signature, reducing the need for the developer to manually provide the same type information about the API in the endpoint metadata.

A functionally equivalent version of this feature is already implemented in the MinimalApis.Extensions library.

Problem

Today, route handler delegates in Minimal APIs are auto-described in ApiExplorer and thus Swagger UI and OpenAPI documents (through libraries like Swashbuckle) including details of the parameters they accept and response they return, e.g.

Program.cs

var todos = new Dictionary<int, Todo>
{
    { 1, new Todo(1, "Buy the groceries") },
    { 2, new Todo(2, "Wash the car") },
    { 3, new Todo(3, "Put out the garbage") }
};

app.MapGet("/todos/{id}", (int id) => todos[id]);

app.Run();

record Todo(int Id, string Title, bool IsCompleted = false);

Swagger UI
image

Note that the input parameter id is correctly named and typed based on the delegate parameter, and the 200 response has a media type of application/json and a body schema based on the Todo type that the delegate returns.

This auto-describing of the API breaks down though when the extensibility options for parameter binding or result processing are utilized, namely TryParse or BindAsync on parameter types, and IResult for return types. e.g.:

Program.cs

app.MapGet("/todos/customid/{id}", (TodoId id) =>
    todos.SingleOrDefault(t => t.Key == id.Id).Value switch
    {
        Todo todo => Results.Ok(todo),
        _ => Results.NotFound()
    });

app.Run();

record Todo(int Id, string Title, bool IsCompleted = false);
record struct TodoId(int Id)
{
    public static bool TryParse(string value, out TodoId result)
    {
        if (int.TryParse(value, out int id))
        {
            result = new TodoId(id);
            return true;
        }

        result = new();
        return false;
    }
}

Swagger UI
image

Note this time that the id parameter is typed as "string" and the 200 response has no media type or schema information.

To restore the API description details, the user is required to manually describe the API via extension methods (or attributes) that add endpoint metadata about the responses, e.g.:

Program.cs

app.MapGet("/todos/customid/{id}", (TodoId id) =>
    todos.SingleOrDefault(t => t.Key == id.Id).Value switch
    {
        Todo todo => Results.Ok(todo),
        _ => Results.NotFound()
    })
    .Produces<Todo>()
    .Produces(StatusCodes.Status404NotFound);

Swagger UI
image

Note that today there is no endpoint metadata supported that will describe API parameters, so the id parameter is still typed incorrectly as string, but the 200 and 404 responses are now described.

Proposed Solution

We should introduce a way for types used in the signature of route handlers (parameters and return values) to add metadata to the associated endpoint. This metadata should be featured enough to properly contribute all API parameter and response details in ApiExplorer and thus Swagger UI and OpenAPI documents.

Interfaces

Two new interfaces will be introduced, one for parameter types and another for return value types. The presence of these interfaces on a type indicates they can contribute to the endpoint metadata.

/// <summary>
/// Marker interface that indicates a type provides a static method that returns <see cref="Endpoint"/> metadata for a
/// parameter on a route handler delegate.
/// </summary>
public interface IProvideEndpointParameterMetadata
{
    static abstract IEnumerable<object> GetMetadata(ParameterInfo parameter, IServiceProvider services);
}

/// <summary>
/// Marker interface that indicates a type provides a static method that returns <see cref="Endpoint"/> metadata for the
/// returned value from a given <see cref="Endpoint"/> route handler delegate.
/// </summary>
public interface IProvideEndpointMetadata
{
    static abstract IEnumerable<object> GetMetadata(MethodInfo methodInfo, IServiceProvider services);
}

Each interface has a single method that will be called by the framework when the endpoint is being built, with the returned objects being added to the endpoint metadata.

The IProvideEndpointParameterMetadata interface's GetMetadata method accepts a ParameterInfo representing the relevant route handler delegate parameter, such that the parameter name, type details, etc. are available, along with the IServiceProvider for the application's DI container.

The IProvideEndpointMetadata interface's GetMetadata method accepts the MethodInfo instance for the endpoint that the returned metadata will be applied to, such that the details of the method are available, along with the IServiceProvider for the application's DI container.

Example

The following is an example of an API that accepts a parameter utilizing custom binding via BindAsync and returns a custom IResult type that describes itself in Swagger UI/OpenAPI by emitting metadata via the
IProvideEndpointParameterMetadata and IProvideEndpointMetadata interfaces:

Program.cs

app.MapGet("/todos", async (TodoDb db) => await db.Todos.ToListAsync());
app.MapGet("/todos/{id}", async (int id, TodoDb db) => await db.Todos.FindAsync(id));
app.MapPost("/todos", async (NewTodo newTodo, TodoDb db) =>
{
    if (newTodo.IsValid && newTodo.Todo is not null)
    {
        db.Todos.Add(newTodo.Todo);
        await db.SaveChangesAsync();
        return new CreateResult<Todo>(newTodo.Todo);
    }
    return new CreateResult<Todo>(newTodo.ValidationErrors);
});

app.Run();

class Todo
{
    public Todo(string? title, bool isComplete = false)
    {
        Title = title;
        IsComplete = isComplete;
    }

    public int Id { get; set; }
    public string? Title { get; set; }
    public bool IsComplete { get; set; }
}

class NewTodo : IProvideEndpointParameterMetadata
{
    private static readonly Todo EmptyTodo = new(default);

    private NewTodo() { }

    private NewTodo(Todo todo)
    {
        switch(todo)
        {
            case { Title: null }:
            case { Title.Length: 0 }:
                ValidationErrors.Add(nameof(Todo.Title), new[] { $"The {nameof(Todo.Title)} field is required." });
                break;
            default:
                Todo = todo;
                break;
        }
    }

    public Todo Todo { get; } = EmptyTodo;

    public bool IsValid => ValidationErrors.Count > 0;

    public Dictionary<string, string[]> ValidationErrors { get; } = new();

    public static async ValueTask<NewTodo> BindAsync(HttpContext context) =>
        await context.Request.ReadFromJsonAsync<Todo>() switch
        {
            Todo todo => new NewTodo(todo),
            _ => new NewTodo()
        };

    public static IEnumerable<object> GetMetadata(ParameterInfo parameter, IServiceProvider services)
    {
        yield return new ConsumesAttribute(typeof(Todo), "application/json");
    }
}

public class GetResult<TResponse> : IResult, IProvideEndpointMetadata
{
    public GetResult(TResponse? response) => Response = response;

    public TResponse? Response { get; set; }

    public async Task ExecuteAsync(HttpContext httpContext) =>
        await (Response switch
        {
            not null => Results.Ok(Response).ExecuteAsync(httpContext),
            _ => Results.NotFound().ExecuteAsync(httpContext)
        });

    public static IEnumerable<object> GetMetadata(MethodInfo methodInfo, IServiceProvider services)
    {
        yield return new ProducesResponseTypeAttribute(typeof(TResponse), StatusCodes.Status200OK, "application/json");
        yield return new ProducesResponseTypeAttribute(StatusCodes.Status404NotFound);
    }
}

public class CreateResult<TResponse> : IResult, IProvideEndpointMetadata
{
    public CreateResult(TResponse response) => Response = response;

    public CreateResult(IDictionary<string, string[]> errors) => ProblemDetails = new(errors);

    public TResponse? Response { get; }

    public HttpValidationProblemDetails? ProblemDetails { get; }

    public async Task ExecuteAsync(HttpContext httpContext) =>
        await (Response switch
        {
            TResponse => Results.Ok(Response).ExecuteAsync(httpContext),
            _ => Results.Problem(ProblemDetails!).ExecuteAsync(httpContext)
        });

    public static IEnumerable<object> GetMetadata(MethodInfo methodInfo, IServiceProvider services)
    {
        yield return new ProducesResponseTypeAttribute(typeof(TResponse), StatusCodes.Status201Created, "application/json");
        yield return new ProducesResponseTypeAttribute(typeof(HttpValidationProblemDetails), StatusCodes.Status400BadRequest, "application/problem+json");
    }
}

Challenges/Open Questions

Existing result types

The existing IResult implementations in the framework are currently internal, although that will likely change as part of #37502. For this feature to be useful out-of-the-box, these result types must be public and be updated (where applicable) to implement IProvideEndpointMetadata so that they can contribute to endpoint metadata.

Result types that represent a response with a body that's based on a type need to be able to convey the schema of the body in their type signature, meaning they should need to be generic, where the generic type argument is the type that represents the resource being returned, e.g. OkResult<Todo>, CreatedAtResult<Todo>, etc.

Also each type needs to completely represent a distinct HTTP result without relying on runtime/instance data, e.g. StatusCodeResult is ambiguous when statically observed as to what the actual HTTP result is and thus will not work, whereas a NotFoundResult can wholly represents an HTTP 404 Not Found result statically.

Results class static factory methods

The current Results static factory methods that all return IResult do not allow the framework to observe the actual concrete types being returned, and thus we'll need to consider adding new methods to create the result types that preserve the concrete type information, or some other solution, e.g.

  • Introduce a new static class, e.g. HttpResults, which has factory methods for the public result types that return the concrete types
  • Introduce a new set of methods on the existing Results class but under a new property, e.g. Results.Typed.NotFound(), etc. (this one is my preference right now)
  • Change the existing static methods to return the now public concrete types instead of IResult (Note along with this being a binary-breaking change, this has implications on the compiler's ability to infer the return type of anonymous lambdas when there's more than one return type in the method so is likely a source-breaking change too)
  • Add overloads to the existing static methods that differ only by return type. This is not possible in C# but can be expressed in MSIL. Then have the .NET 7 ref assemblies only expose the new concrete-type returning overloads while the implementation assembly has both overloads, keeping existing binaries bound to the .NET 6 IResult-returning overloads (this is likely crazy but possibly crazy like a fox, so...). (Note this has the same issue as the point above RE the compiler's ability to infer anonymous lambda return type)
  • Make the in-box result types constructable so that they can be returned directly, e.g. return new NotFoundHttpResult(); (Note this has the same issue as the point above RE the compiler's ability to infer anonymous lambda return type)
  • Add static factory methods to the in-box result types so they can be returned directly, e.g. return NotFoundHttpResult.Create(); (Note this has the same issue as the point above RE the compiler's ability to infer anonymous lambda return type)
  • Something else?

Multiple return types

Most APIs end up having multiple return paths that return different result types depending on the outcome of the API invocation, e.g. returning an OkResult if the resource is found, and a NotFoundResult if it isn't. C# doesn't currently have a language construct that allows a method to declare it returns multiple types (see discriminated unions at dotnet/csharplang#113) but this can be achieved using the type system thanks to generics, e.g.

TodosApi.cs

app.MapGet("/todos/{id}", GetTodoById);

async Task<Results<Ok<Todo>, NotFound>> GetTodoById(int id, IDbConnection db) =>
    await db.QuerySingleOrDefaultAsync<Todo>("SELECT * FROM Todos WHERE Id = @id", new { id })
    is Todo todo
        ? Results.Extensions.Ok(todo)
        : Results.Extensions.NotFound();

See #40672 for details of a proposal to support multiple return types on route handler delegates.

Metadata

Metadata

Assignees

Labels

api-approvedAPI was approved in API review, it can be implementedarea-minimalIncludes minimal APIs, endpoint filters, parameter binding, request delegate generator etcenhancementThis issue represents an ask for new feature or an enhancement to an existing onefeature-minimal-actionsController-like actions for endpoint routingfeature-openapiold-area-web-frameworks-do-not-use*DEPRECATED* This label is deprecated in favor of the area-mvc and area-minimal labels

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions