Skip to content

Introduce Results.Typed factory methods for creating results whose types properly reflect the response type & shape #41009

Closed
@DamianEdwards

Description

@DamianEdwards

Background

The existing Results.xxx() static factory methods all return IResult meaning the explicit type information is lost. Even though the in-box types themselves are now public, their constructors are not, meaning having a route handler delegate return an explicit result type requires the result be cast to its actual type, e.g.:

app.MapGet("/todos/{id}", async (int id, TodoDb db) =>
    (OkObjectHttpResult)Results.Ok(await db.FindAsync(id));

The introduction of the Results<TResult1, TResultN> union types presents a compelling reason to allow the creation of the in-box IResult implementing types in a way that preserves their explicit types. Having to explicitly cast each call to a Results factory method is somewhat unsightly:

app.MapGet("/todos/{id}", async Task<Results<OkObjectHttpResult, NotFoundObjectHttpResult>> (int id, TodoDb db) =>
    await db.FindAsync(id) is Todo todo
        ? (OkObjectHttpResult)Results.Ok(todo)
        : (NotFoundObjectHttpResult)Results.NotFound()

Additionally, the in-box result types today that allow setting an object to be serialized to the response body do not preserve the type information of those objects, including OkObjectHttpResult and others, e.g.

public static IResult Ok(object? value = null) => new OkObjectHttpResult(value);

This means that even if the result type itself were preserved, the type of the underlying response body is not, and as such an OpenAPI schema cannot be inferred automatically, requiring the developer to manually annotate the endpoint with type information:

app.MapGet("/todos/{id}", async (int id, TodoDb db) =>
    (OkObjectHttpResult)Results.Ok(await db.FindAsync(id))
    .Produces<Todo>();

In order to enable result types to properly describe the HTTP result they represent, the type must encapsulate the status code, content type, and response body shape statically by the type shape (i.e. without any runtime knowledge).

Proposal

New results types

Introduce new result types that represent all the supported response status codes (within reason) and preserve the type details of the response body via generics. As these result types all serialize their body object to JSON (and no other format is currently supported by the in-box result types) the content type need not be represented in the type shape. An example of the result types being proposed can be found in the MinimalApis.Extensions library here.

Example of what a new generic result representing an OK response might look like:

namespace Microsoft.AspNetCore.Http;

public class OkHttpResult<TValue> : IResult
{
    internal OkHttpResult(TValue value)
    {
        Value = value;
    }

    public TValue Value { get; init; }

    public int StatusCode => StatusCodes.Status200OK;

    public async Task ExecuteAsync(HttpContext httpContext)
    {
        httpContext.Response.StatusCode = StatusCode;
        if (Value is not null)
        {
            await httpContext.Response.WriteAsJsonAsync(Value);
        }
    }
}

❓ New result type names

As the type names for these new types will actually appear in code (rather than being inferred by the compiler) some extra thought should be given to their names. The existing result type names are somewhat unwieldly, e.g. OkObjectHttpResult, NotFoundObjectHttpResult, and as such don't lend themselves well to the "minimal" approach. In the MinimalApis.Extensions library, the types are named with the assumption that they will be used in conjunction with the Results<TResult1, TResultN> union types, and as such the names are very minimal, just using the response type itself, e.g. Ok, NotFound, etc.

This allows for fairly terse signatures like Task<Result<Ok<Todo>, NotFound>> (int id) but this might be a bit too short to accept in the framework. Some other ideas we should consider:

  • OkHttpResult<T>, NotFoundHttpResult, etc.
  • Putting the new types in their own namespace, Microsoft.AspNetCore.Http.TypedResults and having shorter names, e.g.
    • Microsoft.AspNetCore.Http.TypedResults.Ok<TValue>, Microsoft.AspNetCore.Http.TypedResults.NotFound
    • This would allow the types to be used either like TypedResults.Ok<Todo>, TypedResults.NotFound or by importing the namespace explicitly like Ok<Todo>, NotFound, etc.

Results.Typed factory methods

To preserve the existing pattern of creating result types via the static Results class, we will introduce a new member on the Microsoft.AspNetCore.Http.Results class called Typed, upon which are factory methods that use the new result types and preserve the concrete type of the returned results:

namespace Microsoft.AspNetCore.Http;

public static class Results
{
+ public static ITypedResultExtensions Typed { get; } = new TypedResultExtensions();
}

+ internal class TypedResultExtensions : ITypedResultExtensions { }

+ public static class TypedResultExtensionsMethods
+ {
+      public static OkHttpResult<TResult> Ok<TResult>(this ITypedResultExtensions typedResults, TResult? value)
+      {
+          return new OkHttpResult<TResult>(value);
+      }
+ 
+     // Additional factory result methods
+ }

Example use

app.MapGet("/todos/{id}", async Task<Results<OkHttpResult<Todo>, NotFoundHttpResult>> (int id, TodoDb db) =>
    await db.FindAsync(id) is Todo todo
        ? Results.Typed.Ok(todo)
        : Results.Typed.NotFound());

Making the new results self-describe via metadata

These new result types would be updated once #40646 is implemented, such that the result types can self-describe their operation via metadata into ApiExplorer and through to OpenAPI documents and Swagger UI, resulting in much more of an APIs details being described from just the method type information.

The following two API implementations represent the resulting two different approaches to describing the details of an API for OpenAPI/Swagger, the first using just type information from the route handler delegate, the second requiring explicitly adding type information via metadata. The first means there's compile-time checking that the responses returned are actually declared in the method signature, whereas the second requires the developer to manually ensure the metadata added matches the actual method implementation:

// API parameters, responses, and response body shape described just by method type information
app.MapGet("/todos/{id}", async Task<Results<OkHttpResult<Todo>, NotFoundHttpResult>> (int id, TodoDb db) =>
    await db.FindAsync(id) is Todo todo
        ? Results.Typed.Ok(todo)
        : Results.Typed.NotFound());

// API parameters from method type information, but response details requiring manual metadata specification
app.MapGet("/todos/{id}", async (int id, TodoDb db) =>
    await db.FindAsync(id) is Todo todo
        ? Results.Ok(todo)
        : Results.NotFound())
    .Produces<Todo>()
    .Produces(StatusCodes.Status404NotFound);

Metadata

Metadata

Labels

DocsThis issue tracks updating documentationapi-approvedAPI was approved in API review, it can be implementedarea-minimalIncludes minimal APIs, endpoint filters, parameter binding, request delegate generator etcfeature-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