Skip to content

Minimal APIs | IResult or TypedResult specifically for IAsyncEnumerable #60136

@toupswork

Description

@toupswork

Is there an existing issue for this?

  • I have searched the existing issues

Is your feature request related to a problem? Please describe the problem.

It cannot be that I am the only one to ever think of this, so there must a good reason why there is no member of the Results or TypedResults class specifically designed for IAsyncEnumerable<T>.

The reason that I think it should be there is because if I return IAsyncEnumerable<T> directly from my endpoint, then that makes it difficult to also be able to return Results.Problem() (for FluentValidation) or Result.Status() if an error occurs before the stream. That's of course because the return type cannot be inferred, because one is IResult and the other is IAsyncEnumerable<T>.

I did build my own IResult to get around this, and in the process of doing so, my throughput jumped dramatically. I do not know for certain why, but I speculate that it may have something to do with the fact that I am using a JsonSerializerContext and source generated (de)serialization code, but unbeknownst to me, even though the type I was returning was decorated with the JsonSerializable attribute, what actually needed to be decorated was my type being wrapped with IAsyncEnumerable.

Describe the solution you'd like

All I am asking is that there be method, call it what you will, or at least give an overload, on TypedResults and/or Results specifically for IAsyncEnumerable


Here's the code I wrote. If anything, I hope this helps someone else out who is running into the same problem.
The ServiceResultAccessor is just a type I created that is similar in concept to IHttpContextAccessor in that it uses AsyncLocal<T> for storing, not HttpContext, but a result status enum. So if there is any error on the backend pulling the data that I am sending out, the status will be written to it, then when I await the first entry in the IAsyncEnumerable, I am able to see if a backend error occurs and update the response status code to reflect that. This provides me a workaround for not being able to use the Result<T> pattern when using a stream.

// ReSharper disable StaticMemberInGenericType

using System.Reflection;
using System.Runtime.CompilerServices;
using API.Logging;
using Microsoft.AspNetCore.Http.Metadata;
using Services.ApiClients.Infrastructure;
using static API.Serialization.ApiJsonContext;
using static Microsoft.AspNetCore.Http.StatusCodes;
namespace API.ApiResults;

public sealed class AsyncEnumerableResult<T>(IAsyncEnumerable<T> stream, ServiceResultStatusAccessor accessor, CancellationToken cancellationToken): IEndpointMetadataProvider, IResult
{
  private static readonly JsonSerializerOptions JsonOptions = Default.Options;
  private const string JsonContentType = "application/json; charset=utf-8";

  public async Task ExecuteAsync(HttpContext httpCtx)
  {
    var (response, logger) = (httpCtx.Response, httpCtx.RequestServices.GetService<ILogger<AsyncEnumerableResult<T>>>());
    try
    {
      await using var enumerator = stream.GetAsyncEnumerator(cancellationToken);

      switch (await enumerator.MoveNextAsync(cancellationToken))
      {
        // some problem occurred in the processing, write the status code and bail
        case true or false when accessor.IsResultFailed(out var status):
          if(!response.HasStarted) response.StatusCode = status.ToHttpStatusCodeInt();
          logger?.AsyncEnumerableErrorStatusCode(response.StatusCode);
          break;

        case false:
          if (!response.HasStarted) response.StatusCode = Status200OK;
          await response.WriteAsJsonAsync(GetEmpty(), JsonOptions, cancellationToken);
          break;

        case true:
          if (!response.HasStarted) response.StatusCode = Status200OK;
          await response.WriteAsJsonAsync(GetRemainingItems(enumerator, cancellationToken), JsonOptions, cancellationToken);
          break;
      }
    }
    catch (Exception ex)
    {
      logger?.AsyncEnumerableResultException(typeof(T).Name, ex);
    }
  }

  private static IAsyncEnumerable<T> GetEmpty() => AsyncEnumerable.Empty<T>();
  private static async IAsyncEnumerable<T> GetRemainingItems(IAsyncEnumerator<T> enumerator, [EnumeratorCancellation] CancellationToken ct)
  {
    yield return enumerator.Current;
    while (await enumerator.MoveNextAsync(ct)) yield return enumerator.Current;
  }

  static void IEndpointMetadataProvider.PopulateMetadata(MethodInfo method, EndpointBuilder builder)
  {
    builder.Metadata.Add(new ProducesResponseTypeMetadata(Status200OK, typeof(IAsyncEnumerable<T>), [JsonContentType]));
    builder.Metadata.Add(new ProducesResponseTypeMetadata(Status400BadRequest));
    builder.Metadata.Add(new ProducesResponseTypeMetadata(Status401Unauthorized));
    builder.Metadata.Add(new ProducesResponseTypeMetadata(Status404NotFound));
    builder.Metadata.Add(new ProducesResponseTypeMetadata(Status500InternalServerError));
    builder.Build();
  }
}

Additional context

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-minimalIncludes minimal APIs, endpoint filters, parameter binding, request delegate generator etc

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions