Skip to content

Introduce way for route handler delegates (Minimal APIs) to return union results (i.e. multiple IResult types) #40672

@DamianEdwards

Description

@DamianEdwards

Overview

Introduce the capability for route handler delegates to declare they return a union of multiple concrete implementations of IResult via their signature. This would provide compile-time checking that the delegate returns only the types declared, and when combined with #40646 allows the possible result types for the endpoint to be described in ApiExplorer automatically without the need for adding explicit metadata.

Problem

Today, the type a route handler delegate declares it returns in its signature governs the default behavior of the framework when that delegate is invoked to handle a request. Returned types that implement IResult have their Execute method called once returned from the delegate, and all other types are serialized to the HTTP response body as JSON (including support for unwrapping Task<T> results>.

For very simple APIs this is satisfactory. Consider the following API:

app.MapGet("/todos", async (TodoDb db) => await db.Todos.ToListAsync());

The passed route handler delegate will have its return type inferred by the compiler as Task<List<Todo>>. The framework will automatically JSON serialize the List<Todo> return value to the response body with a status code of 200 OK.

Very often however, APIs need to be able to return different responses depending on the input, etc. Consider the following API:

app.MapGet("/todos/{id}", async (int id, TodoDb db) =>
{
    await db.Todos.FindAsync(id) switch
    {
        Todo todo => Results.Ok(todo),
        _ => Results.NotFound()
    };
});

This API returns either a 200 OK IResult if the Todo with the supplied id is found, or a 404 Not Found IResult if no matching Todo is found. All return paths in the delegate must now return IResult, as the delegate can have only a single return type (which is inferred by the compiler in this case as both the Results.Ok() and Results.NotFound() methods return IResult). The framework observes that the delegate returns IResult and as such implements the behavior of calling the Execute method on whichever IResult instance the delegate returns when handling a request.

In some cases, the API will need to explicitly specify the return type of the delegate as the compiler will be unable to infer it. Consider the following API that uses a custom IResult type for one of the delegate's return paths. In order to enable returning different IResult types, the method must declare its return type as just IResult, meaning there's no information via the delegate signature as to exactly which IResult implementing types could be returned:

app.MapGet("/todos/{id}", async IResult (int id, TodoDb db) =>
{
    await db.Todos.FindAsync(id) switch
    {
        Todo todo => Results.Ok(todo),
        _ => new MyCustomResult(404, "No Todo Found")
    };
});

class MyCustomResult : IResult
{
    public MyCustomResult(int statusCode, string message)
    {
        StatusCode = statusCode;
        Message = message;
    }
    public int StatusCode { get; } = 200;
    public string Message { get; } = "Not Found";
    public async Task ExecuteAsync(HttpContext httpContext)
    {
        httpContext.Response.Status = StatusCode;
        await httpContext.Response.Body.WriteAsync(Message);
    }
}

Without the concrete type information of the possible return types observable from the delegate signature, the framework is unable to implement any automatic behavior based on what the delegate might return, which is required along with #40646 to enable the API to be automatically more completely described in ApiExplorer (for the purposes of Swagger UI and OpenAPI documents).

Proposed Solution

Option 1 (Today?): Introduce an IResult union type in the framework, e.g. Results<T1, T2, TN>

ℹ️ There is an example implementation of this option in the Results<T1, T2, ..> type in the MinimalApis.Extension library

We would introduce a new generic type in the ASP.NET Core framework that implements IResult and accepts multiple generic type arguments (constrained to IResult) for the different result types a delegate can possibly return. This type would be implicitly convertible from any of it's generic argument types to itself, such that it need only be declared as the return type of delegate, and never actually created by user code.

ResultsOfT.cs

public sealed class Results<TResult1, TResult2, TResult3, TResultN> : IResult
    where TResult1 : IResult
    where TResult2 : IResult
    where TResult3 : IResult
{
    private Results(IResult actualResult) => ActualResult = actualResult;

    public IResult ActualResult { get; }

    public async Task ExecuteAsync(HttpContext httpContext) => await ActualResult.ExecuteAsync(httpContext);

    public static implicit operator Results<TResult1, TResult2, TResult3>(TResult1 result) => new(result);
    public static implicit operator Results<TResult1, TResult2, TResult3>(TResult2 result) => new(result);
    public static implicit operator Results<TResult1, TResult2, TResult3>(TResult3 result) => new(result);
}

Consider the following API which has been updated from before to now declare that it returns either OkResult<Todo> or NotFoundResult:

app.MapGet("/todos/{id}", async Task<Results<OkResult<Todo>, NotFoundResult>> (int id, TodoDb db) =>
{
    await db.Todos.FindAsync(id) switch
    {
        Todo todo => new OkResult<Todo>(todo),
        _ => new NotFoundResult()
    };
});

The compiler will ensure that only IResult types declared in the delegate's return signature are returned, and the Results<T1, T2, TN> type takes care of the implicit conversion of the declared concrete result types to itself. Additionally, when this delegate is observed by the framework, the full type information about which result types it can return is preserved, allowing for more capabilities regarding the default behavior employed.

A more complete example of an Minimal APIs application utilizing this approach can be found in this sample of the MinimalApis.Extensions library.

Option 2 (future?): Leverage a C# union type feature

If C# introduces support for declaring and utilizing union types, that could be utilized directly. It's similar to the approach in option 1 but a type would not be required in the framework.

The details of this would of course depend on how union types are implemented in C#, but consider the following example for a rough idea:

// Compiler inferred union type (inferred anonymous union type)
app.MapGet("/todos/{id}", async union (int id, TodoDb db) =>
{
    await db.Todos.FindAsync(id) switch
    {
        Todo todo => new OkResult<Todo>(todo),
        _ => new NotFoundResult()
    };
});

// Union type declared inline (anonymous union type)
app.MapGet("/todos/{id}", async (OkResult<Todo> | NotFoundResult) (int id, TodoDb db) =>
{
    await db.Todos.FindAsync(id) switch
    {
        Todo todo => new OkResult<Todo>(todo),
        _ => new NotFoundResult()
    };
});

// Explicitly defined union type
app.MapGet("/todos/{id}", async Task<GetResult<Todo>> (int id, TodoDb db) =>
{
    await db.Todos.FindAsync(id) switch
    {
        Todo todo => new OkResult<Todo>(todo),
        _ => new NotFoundResult()
    };
});

enum struct GetResult<T> { OkResult<T> Ok, NotFoundResult NotFound }

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 routingold-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