question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

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

See original GitHub issue

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);

Issue Analytics

  • State:closed
  • Created a year ago
  • Reactions:1
  • Comments:22 (22 by maintainers)

github_iconTop GitHub Comments

2reactions
brunolins16commented, Apr 8, 2022

Here is my proposal:

Typed Results static methods

They are almost the same available at https://github.com/dotnet/aspnetcore/blob/main/src/Http/Http.Results/src/Results.cs

namespace Microsoft.AspNetCore.Http;

public static partial class Results
{
+    public static class Typed
+    {
+        public static Challenge Challenge(
+            AuthenticationProperties? properties = null,
+            IList<string>? authenticationSchemes = null) {}
       
+        public static Forbid Forbid(
+            AuthenticationProperties? properties = null, 
+            IList<string>? authenticationSchemes = null) {}
       
+        public static SignIn SignIn(
+            ClaimsPrincipal principal,
+            AuthenticationProperties? properties = null,
+            string? authenticationScheme = null) {}
       
+        public static SignOut SignOut(
+            AuthenticationProperties? properties = null, 
+            IList<string>? authenticationSchemes = null) {}
       
+        public static Content Content(
+            string content, 
+            string? contentType = null, 
+            Encoding? contentEncoding = null) {}
       
+        public static Content Text(
+            string content, 
+            string? contentType = null, 
+            Encoding? contentEncoding = null) {}
       
+        public static Content Content(
+            string content, 
+            MediaTypeHeaderValue contentType) {}
       
+        public static Json<TValue> Json<TValue>(
+            TValue? data, 
+            JsonSerializerOptions? options = null, 
+            string? contentType = null, 
+            int? statusCode = null)  {}
       
+        public static FileContent File(
+            byte[] fileContents,
+            string? contentType = null,
+            string? fileDownloadName = null,
+            bool enableRangeProcessing = false,
+            DateTimeOffset? lastModified = null,
+            EntityTagHeaderValue? entityTag = null)  {}
       
+        public static FileContent Bytes(
+            byte[] contents,
+            string? contentType = null,
+            string? fileDownloadName = null,
+            bool enableRangeProcessing = false,
+            DateTimeOffset? lastModified = null,
+            EntityTagHeaderValue? entityTag = null)
+            => new(contents, contentType)  {}
       
+        public static FileContent Bytes(
+            ReadOnlyMemory<byte> contents,
+            string? contentType = null,
+            string? fileDownloadName = null,
+            bool enableRangeProcessing = false,
+            DateTimeOffset? lastModified = null,
+            EntityTagHeaderValue? entityTag = null) {}
       
+        public static HttpFileStream File(
+            Stream fileStream,
+            string? contentType = null,
+            string? fileDownloadName = null,
+            DateTimeOffset? lastModified = null,
+            EntityTagHeaderValue? entityTag = null,
+            bool enableRangeProcessing = false)  {}
       
+        public static HttpFileStream Stream(
+            Stream stream,
+            string? contentType = null,
+            string? fileDownloadName = null,
+            DateTimeOffset? lastModified = null,
+            EntityTagHeaderValue? entityTag = null,
+            bool enableRangeProcessing = false) {}
       
+        public static HttpFileStream Stream(
+            PipeReader pipeReader,
+            string? contentType = null,
+            string? fileDownloadName = null,
+            DateTimeOffset? lastModified = null,
+            EntityTagHeaderValue? entityTag = null,
+            bool enableRangeProcessing = false) {}
       
+        public static PushStream Stream(
+            Func<Stream, Task> streamWriterCallback,
+            string? contentType = null,
+            string? fileDownloadName = null,
+            DateTimeOffset? lastModified = null,
+            EntityTagHeaderValue? entityTag = null)  {}
       
+        public static PhysicalFile PhysicalFile(
+            string path,
+            string? contentType = null,
+            string? fileDownloadName = null,
+            DateTimeOffset? lastModified = null,
+            EntityTagHeaderValue? entityTag = null,
+            bool enableRangeProcessing = false)  {}
       
+        public static VirtualFile VirtualFile(
+            string path,
+            string? contentType = null,
+            string? fileDownloadName = null,
+            DateTimeOffset? lastModified = null,
+            EntityTagHeaderValue? entityTag = null,
+            bool enableRangeProcessing = false) {}
       
+        public static Redirect Redirect(
+            string url, 
+            bool permanent = false, 
+            bool preserveMethod = false) {}
       
+        public static Redirect LocalRedirect(
+            string localUrl, 
+            bool permanent = false, 
+            bool preserveMethod = false) {}
       
+        public static RedirectToRoute RedirectToRoute(
+            string? routeName = null, 
+            object? routeValues = null, 
+            bool permanent = false, 
+            bool preserveMethod = false, 
+            string? fragment = null) {}
       
+        public static Status StatusCode(int statusCode) {}
       
+        public static NotFound NotFound() {}
       
+        public static NotFound<TValue> NotFound<TValue>(TValue? value) {}
       
+        public static Unauthorized Unauthorized() {}
       
+        public static BadRequest BadRequest() {}
       
+        public static BadRequest<TValue> BadRequest<TValue>(TValue? error) {}
       
+        public static Conflict Conflict() {}
       
+        public static Conflict<TValue> Conflict<TValue>(TValue? error) {}
       
+        public static NoContent NoContent() {}
       
+        public static Ok Ok() {}
       
+        public static Ok<TValue> Ok<TValue>(TValue? value) {}
       
+        public static UnprocessableEntity UnprocessableEntity() {}
       
+        public static UnprocessableEntity<TValue> UnprocessableEntity<TValue>(TValue? error) {}
       
+        public static Problem Problem(
+            string? detail = null,
+            string? instance = null,
+            int? statusCode = null,
+            string? title = null,
+            string? type = null,
+            IDictionary<string, object?>? extensions = null) {}
       
+        public static Problem Problem(ProblemDetails problemDetails) {}
       
+        public static Problem ValidationProblem(
+            IDictionary<string, string[]> errors,
+            string? detail = null,
+            string? instance = null,
+            int? statusCode = null,
+            string? title = null,
+            string? type = null,
+            IDictionary<string, object?>? extensions = null) {}
              
+        public static Created Created(string uri) {}
       
+        public static Created<TValue> Created<TValue>(
+            string uri, 
+            TValue? value) {}       

+        public static Created Created(Uri uri) {}
       
+        public static Created<TValue> Created<TValue>(
+            Uri uri, 
+            TValue? value) {}
       
+        public static CreatedAtRoute CreatedAtRoute(
+            string? routeName = null, 
+            object? routeValues = null) {}
       
+        public static CreatedAtRoute<TValue> CreatedAtRoute<TValue>(
+            TValue? value, 
+            string? routeName = null, 
+            object? routeValues = null) {}
       
+        public static Accepted Accepted(string uri) {}
       
+        public static Accepted<TValue> Accepted<TValue>(
+            tring uri, 
+            TValue? value) {}
       
+        public static Accepted Accepted(Uri uri) {}
       
+        public static Accepted<TValue> Accepted<TValue>(
+            Uri uri, 
+            TValue? value) {}
       
+        public static AcceptedAtRoute AcceptedAtRoute(
+            string? routeName = null, 
+            object? routeValues = null) {}
       
+        public static AcceptedAtRoute<TValue> AcceptedAtRoute<TValue>(
+            TValue? value, 
+            string? routeName = null, 
+            object? routeValues = null) {}       
+    }
}

New IResult types with type parameter


+namespace Microsoft.AspNetCore.Http.HttpResults;

+public sealed class AcceptedAtRoute<TValue> : IResult
+{
+    public TValue? Value { get; }
+    public string? RouteName { get; }
+    public RouteValueDictionary RouteValues { get; }
+    public int StatusCode => StatusCodes.Status202Accepted;

+    public Task ExecuteAsync(HttpContext httpContext) {}
+}

+public sealed class Accepted<TValue> : IResult
+{
+    public TValue? Value { get; }
+    public int StatusCode => StatusCodes.Status202Accepted;
+    public string? Location { get; }

+    public Task ExecuteAsync(HttpContext httpContext) {}
+}

+public sealed class BadRequest<TValue> : IResult
+{
+    public TValue? Value { get; }
+    public int StatusCode => StatusCodes.Status400BadRequest;
+
+    public Task ExecuteAsync(HttpContext httpContext){}
+}

+public sealed class Conflict<TValue> : IResult
+{
+    public TValue? Value { get; }
+    public int StatusCode => StatusCodes.Status409Conflict;
+
+    public Task ExecuteAsync(HttpContext httpContext){}
+}

+public sealed class CreatedAtRoute<TValue> : IResult
+{
+    public TValue? Value { get; }
+    public string? RouteName { get; }
+    public RouteValueDictionary RouteValues { get; }
+    public int StatusCode => StatusCodes.Status201Created;

+    public Task ExecuteAsync(HttpContext httpContext) {}
+}

+public sealed class Created<TValue> : IResult
+{
+    public TValue? Value { get; }
+    public int StatusCode => StatusCodes.Status201Created;
+    public string? Location { get; }

+    public Task ExecuteAsync(HttpContext httpContext) {}
+}

-public sealed partial class JsonHttpResult : IResult
+public sealed partial class Json<TValue> : IResult
{
-    public object Value { get; }
+    public TValue? Value { get; }
}

+public sealed class Ok<TValue> : IResult
+{
+    public TValue? Value { get; }
+    public int StatusCode => StatusCodes.Status200OK;
+
+    public Task ExecuteAsync(HttpContext httpContext){}
+}

+public sealed class UnprocessableEntity<TValue> : IResult
+{
+    public TValue? Value { get; }
+    public int StatusCode => StatusCodes.Status422UnprocessableEntity;
+
+    public Task ExecuteAsync(HttpContext httpContext){}
+}

Also, the proposal is to change the current types to have a shorter name, move to a new namespace and remove the object Value properties:


-namespace Microsoft.AspNetCore.Http;
+namespace Microsoft.AspNetCore.Http.HttpResults;

-public sealed class AcceptedHttpResult : IResult
+public sealed class Accepted : IResult
{
-    public object? Value { get; }    
}

-public sealed class AcceptedAtRouteHttpResult : IResult
+public sealed class AcceptedAtRoute : IResult
{
-    public object? Value { get; }    
}

-public sealed class BadRequestObjectHttpResult : IResult
+public sealed class BadRequest : IResult
{
-    public object? Value { get; }    
}

-public sealed class ChallengeHttpResult : IResult
+public sealed class Challenge : IResult {   }

-public sealed class ConflictObjectHttpResult : IResult
+public sealed class Conflict : IResult
{
-    public object? Value { get; }    
}

-public sealed class ContentHttpResult : IResult
+public sealed class Content : IResult 
{   
-    public string? Content { get; }   
+    public string? ResponseContent { get; }       
}

-public sealed class CreatedHttpResult : IResult
+public sealed class Created : IResult
{
-    public object? Value { get; }    
}

-public sealed class CreatedAtRouteHttpResult : IResult
+public sealed class CreatedAtRoute : IResult
{
-    public object? Value { get; }    
}

-public sealed class EmptyHttpResult : IResult
+public sealed class Empty : IResult {   }

-public sealed class ForbidHttpResult : IResult
+public sealed class Forbid : IResult {   }

-public sealed class FileStreamHttpResult : IResult
+public sealed class HttpFileStream : IResult {   }

-public sealed class NoContentHttpResult : IResult
+public sealed class NoContent : IResult {   }

-public sealed class NotFoundObjectHttpResult : IResult
+public sealed class NotFound : IResult
{
-    public object? Value { get; }    
}

-public sealed class OkObjectHttpResult : IResult
+public sealed class Ok : IResult
{
-    public object? Value { get; }    
}

-public sealed class PhysicalFileHttpResult : IResult
+public sealed class PhysicalFile : IResult {   }

-public sealed class ProblemHttpResult : IResult
+public sealed class Problem : IResult {   }

-public sealed class PushStreamHttpResult : IResult
+public sealed class PushStream : IResult {   }

-public sealed class RedirectHttpResult : IResult
+public sealed class Redirect : IResult {   }

-public sealed class RedirectToRouteHttpResult : IResult
+public sealed class RedirectToRoute : IResult {   }

-public sealed class SignInHttpResult : IResult
+public sealed class SignIn : IResult {   }

-public sealed class SignOutHttpResult : IResult
+public sealed class SignOut : IResult {   }

-public sealed class StatusCodeHttpResult : IResult
+public sealed class Status : IResult {   }

-public sealed class UnauthorizedHttpResult : IResult
+public sealed class Unauthorized : IResult {   }

-public sealed class UnprocessableEntityObjectHttpResult : IResult
+public sealed class UnprocessableEntity : IResult
{
-    public object? Value { get; }    
}

-public sealed class VirtualFileHttpResult : IResult
+public sealed class VirtualFile : IResult {   }
2reactions
brunolins16commented, Apr 7, 2022

After a quick look, might missed something, in addition to the example I mentioned we will only have conflict with:

VirtualFileHttpResult -> https://docs.microsoft.com/en-us/dotnet/api/system.web.hosting.virtualfile?view=netframework-4.8 Not in .NET Core JsonHttpResult -> https://docs.microsoft.com/en-us/dotnet/api/system.web.helpers.json?view=aspnet-webpages-3.2 Don’t know if is possible to use it

So, maybe if we decide a better name to FileStreamHttpResult other than FileStream we might be able to have the short names without problems.

Read more comments on GitHub >

github_iconTop Results From Across the Web

Factory method for designing pattern
The Factory Method pattern is used to create objects without specifying the exact class of object that will be created. This pattern is...
Read more >
The Factory Method Pattern and Its Implementation in Python
The book describes design patterns as a core design solution to reoccurring problems in software and classifies each design pattern into categories according...
Read more >
Effective Dart: Design
Here are some guidelines for writing consistent, usable APIs for libraries. Names. Naming is an important part of writing readable, maintainable code.
Read more >
What's new in .NET 8
Learn about the new .NET features introduced in .NET 8.
Read more >
Opinion Paper: “So what if ChatGPT wrote it? ...
With this background, in this article we seek to answer the two following research questions: RQ1) What are the opportunities, challenges, and implications ......
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found