Skip to content

AffResult Pattern #2

@WorldMaker

Description

@WorldMaker

I may clean this up even further and blog it at some point too, but since this is the "official" repo for this sort of thing per the LanguageExt wiki, I thought it might be most useful documented here in this repo somewhere:

The AffResult<T> Pattern

I thought it would be a good idea to document this LanguageExt pattern I helped my team develop for making ASP.NET (Core) handlers easier and more elegant to write as somewhat purely LanguageExt Aff pipelines.

At the moment this is just an application-specific pattern and not something that I think could be easily refactored as a reusable library because it is strongly tied to the application-specific runtime, error patterns, and REST design considerations.

Also, I've not yet figured out how this pattern changes (or doesn't) for LanguageExt v5.

The backing intuition behind this pattern is that ASP.NET's IResult is a powerful abstraction that is open to extension and built with the goal in mind that pieces can be easily lego-bricked together.

An AffResult<T> class can implement IResult and an implicit conversion from Aff<HttpRuntime, T> and the outer handler of an endpoint only needs to specialize a generic Aff<RT, T> pipeline. The Runtime injection can be handled by AffResult<T> and that class can be a centralized place for mapping Errors to error statuses and error results, logging/tracing, and other concerns.

A short usage example:

interface MyEndpointService
{
   Aff<RT, Something> GetSomething<RT>(SomeDependency dep)
      where RT : struct, HasCancel<RT>;
}

// all the Handle function needs to do is specialize the Aff to the expected runtime
AffResult<Something> Handle(SomeDependency dep) => GetSomething<HttpRuntime>(dep);

app.MapGet("/api/something", Handle);

Demonstration HttpRuntime

An HttpRuntime and related classes that might be useful in an ASP.NET application:

public record RequestValidationFailed(/* fields */)
   : Expected(/* details */);

public record RequestValidationFailedResponse(/* fields */)
{
   public static ResponseValidationFailed From(RequestValidationFailed error) => throw new NotImplementedException(); // TODO
}

public record HttpRuntimeEnv(/* environment dependencies */);

public readonly record struct HttpRuntime(HttpRuntimeEnv env, CancellationToken ct)
   : HasCancel<RT> /* other traits, etc */
{
   /* implement HasCancel<RT>, etc */

   public static HttpRuntime New(HttpRuntimeEnv env, CancellationToken ct) => new(env, ct);

   public static readonly Error NotFound = Error.New("Not found");
   /* other HTTP status errors as desired */
}

The big thing to point out here is some standard Error objects for various HTTP code results.

AffResult<T>

Given an HttpRuntime like above, the implementation of an AffResult<T> is relatively straightforward:

using System.Reflection;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Http.Metadata;

namespace ExampleAffResult;

public class AffResult<T>(Aff<HttpRuntime, T> httpAff)
   : IResult, // main abstraction
   IEndpointMetadataProvider // abstraction for providing metadata to Swagger/OpenAPI, etc
{
   public Aff<HttpRuntime, T> HttpAff { get; } = httpAff;

   // *** Metadata ***

   // IEndpointMetadataProvider is a static interface, this function is just to make the static interface cast easier for "chaining" to other IResults' metadata; these are protected because AffResult<T> might be further subclassed for even more specialized results
   protected static void PopulateMetadata<TTarget>(MethodInfo method, EndpointBuilder builder)
      where TTarget : IEndpointMetadataProvider
   {
      TTarget.PopulateMetadata(method, builder);
   }

   protected static void PopulateErrorMetadata(MethodInfo method, EndpointBuilder builder)
   {
      PopulateMetadata<BadRequest<RequestValidationFailedResponse>>(method, builder);
      PopulateMetadata<NotFound>(method, builder);

      /* other errors your app may want to provide return metadata on */
   }

   public static void PopulateMetadata(MethodInfo method, EndpointBuilder builder)
   {
      // Fast path "fire-and-forget" APIs that are Aff<Unit> to HTTP 204
      if (typeof(T).IsAssignableTo(typeof(Unit)))
      {
         PopulateMetadata<NoContent>(method, builder);
      }
      else
      {
         PopulateMetadata<Ok<T>>(method, builder);
      }
      PopulateErrorMetadata(method, builder);
   }

   // *** Execution, where all the runtime magic happens ***
   
   public async Task ExecuteAsync(HttpContext httpContext)
   {
      var endpoint = httpContext.GetEndpoint();
      var ct = httpContext.RequestAborted;
      var env = httpContext.RequestServices.GetRequiredService<HttpRuntimeEnv>();
      var logger = httpContext.RequestServices.GetRequiredService<ILogger>();
      var runtime = HttpRuntime.New(env, ct);
      var result = await HttpAff.Run(runtime);
      var response = result.Match(
         Succ: GetSuccessResult,
         Fail: error =>
         {
            var endpointName = endpoint?.DisplayName ?? "endpoint";
            return GetErrorResult(httpContext, error, logger, endpointName);
         });
      await response.ExecuteAsync(httpContext);
   }

   protected virtual IResult GetSuccessResult(T value) =>
      value switch
      {
         IResult result => result,
         Unit => TypedResults.NoContent(),
         _ => TypedResults.Ok(value)
      };

   
   protected virtual IResult GetErrorResult(HttpContext context, Error ex, ILogger logger, string endpointName)
   { 
      switch (ex)
      {
         RequestValidationFailed badRequest:
            logger.LogWarning(badRequest.ToException(), $"Failed validation in {EndpointName}", endpointName);
            return TypedResults.BadRequest(RequestValidationFailedResponse.From(badRequest));
         Error notFound when notFound == HttpRuntime.NotFound:
            return TypedResults.NotFound();
         /* other errors */
         // fallback to 500
         default:
            logger.LogError(ex.ToException(), "Internal service error in {EndpointName}", endpointName);
            return TypeResults.StatusCode(500);
      }
   }

   // *** Implicit conversions for easy usage ***
   
   public static implicit operator AffResult<T>(Aff<HttpRuntime, T> aff) => new(aff);
   public static implicit operator AffResult<T>(Aff<T> aff) => new(aff);
   public static implicit operator AffResult<T>(Eff<HttpRuntime, T> aff) => new(aff);
   public static implicit operator AffResult<T>(Eff<T> aff) => new(aff);
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions