Description
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);
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;
}
}
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);
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.
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.