LanguageExt.AspNetCore.NativeTypes 0.2.0

dotnet add package LanguageExt.AspNetCore.NativeTypes --version 0.2.0
NuGet\Install-Package LanguageExt.AspNetCore.NativeTypes -Version 0.2.0
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="LanguageExt.AspNetCore.NativeTypes" Version="0.2.0" />
For projects that support PackageReference, copy this XML node into the project file to reference the package.
paket add LanguageExt.AspNetCore.NativeTypes --version 0.2.0
#r "nuget: LanguageExt.AspNetCore.NativeTypes, 0.2.0"
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
// Install LanguageExt.AspNetCore.NativeTypes as a Cake Addin
#addin nuget:?package=LanguageExt.AspNetCore.NativeTypes&version=0.2.0

// Install LanguageExt.AspNetCore.NativeTypes as a Cake Tool
#tool nuget:?package=LanguageExt.AspNetCore.NativeTypes&version=0.2.0

LanguageExt.AspNetCore.NativeTypes

NuGet Badge

Extensions and middleware for ASP.NET Core that allow you to use LanguageExt types directly in controllers. The 3 main goals of this library are to enable:

  • Model binding support for LanguageExt types in controller methods
  • Configurable JSON serialization support for LanguageExt types
    • Currently, there is only support for System.Text.Json.
    • NewtonsoftJson support will be added eventually.
  • Transparent Monad -> IActionResult mapping to allow defining controller methods in terms of monadic chains
    [HttpGet]
    public Eff<string> Hello() => SuccessEff("world");
    

This library is in early development and should not be used in a production setting.

I've made this mostly to satisfy my own curiosity in how far we can push non-functional code to the edges in C#/AspNetCore.

Installation

Add the nuget package to your project

dotnet add package LanguageExt.AspNetCore.NativeTypes

Call AddLanguageExtTypeSupport() after AddMvc() within your ConfigureServices() method to enable support for LanguageExt types.

var builder = WebApplication.CreateBuilder();

builder.WebHost
    .ConfigureServices((IServiceCollection services) =>
    {
        services
            .AddMvc()
            .AddLanguageExtTypeSupport() // <-- Add this
            ;
    });

AddLanguageExtTypeSupport also supports an overload which takes a LanguageExtAspNetCoreOptions instance. This allows you to configure certain aspects of the model binding system.

var builder = WebApplication.CreateBuilder();

builder.WebHost
    .ConfigureServices((IServiceCollection services) =>
    {
        services
            .AddMvc()
            .AddLanguageExtTypeSupport(new LanguageExtAspNetCoreOptions {
                // override default options
            })
            ;
    });

Inbound Type Binding

The following types are supported in controllers as input arguments.

Option<T>

The following binding sources are supported for Option<T> and the implicit OptionNone types.

[HttpPost("body")]
public IActionResult FromBody([FromBody] Option<int> num);

// public record RecordWithOptions(Option<int> Num1, Option<int> Num2);
[HttpPost("body/complex")]
public IActionResult FromBodyComplex([FromBody] RecordWithOptions value);

[HttpGet("query")]
public IActionResult FromQuery([FromQuery] Option<int> num);

[HttpGet("route/{num?}")]
public IActionResult FromRoute([FromRoute] Option<int> num);

[HttpGet("header")]
public IActionResult FromHeader([FromHeader(Name = "X-OPT-NUM")] Option<int> num);

[HttpPost("form")]
public IActionResult FromForm([FromForm] Option<int> num);

Collection types

The following LanguageExt collection types are supported:

  • Seq<T>
  • Lst<T>

Note: The examples below show only Seq<T>, but all supported collection types are interchangable.

[HttpPost("body")]
IActionResult FromBody([FromBody] Seq<int> num);

// public record RecordWithSeqs(Seq<int> First, Seq<int> Second);
[HttpPost("body/complex")]
public IActionResult FromBodyComplex([FromBody] RecordWithSeqs value);

[HttpGet("query")]
public IActionResult FromQuery([FromQuery] Seq<int> num);

[HttpGet("route/{num?}")]
public IActionResult FromRoute([FromRoute] Seq<int> num);

[HttpGet("header")]
public IActionResult FromHeader([FromHeader(Name = "X-OPT-NUM")] Seq<int> num);

[HttpPost("form")]
public IActionResult FromForm([FromForm] Seq<int> num);

JSON Serialization

In addition to allowing LangExt types in controller arguments, these types can be returned in JSON return types with configurable representations.

Option

There are 2 serialization strategies available for Option<T>. You may choose the strategy when calling AddLanguageExtTypeSupport. All serialization strategies are capable of deserializing Option<T> from any form. These strategies only affect how serializing Option<T> -> string is performed.

AsNullable

This strategy uses JSON null to represent None and transparently converts Some values to their normal JSON representations. This is the default serialization strategy for Option<T>.

Set with:

new LanguageExtAspNetCoreOptions { 
    OptionSerializationStrategy = OptionSerializationStrategy.AsNullable 
}

Examples: | Input object | JSON |-------------- |--------- | Some(7) | 7 | new {count: Some(7)} | {count: 7} | None | null | new {count: None} | {count: null}

Attribution: Initial work for this converter came from this GitHub comment

AsArray

This strategy treats the Option<T> as a special case array of 0..1 values. This will always produce a JSON array, but will only write a value into the array when in the Some state.

Set with:

new LanguageExtAspNetCoreOptions { 
    OptionSerializationStrategy = OptionSerializationStrategy.AsArray 
}

Examples: | Input object | JSON |-------------- |--------- | Some(7) | [7] | new {count: Some(7)} | {count: [7]} | None | [] | new {count: None} | {count: []}

Attribution: The majority of the work for this converter came from this GitHub comment

Collection types

All collection types (Seq<T>, etc) that we support from LanguageExt are serialized as arrays, as you would expect.

There are currently no serialization options for these types.

Controller Return Types

Eff/Aff

Returning an Eff<T> or Aff<T> from a controller method will cause the effect to be run. Successes will return 200 and return the value in the response. Error cases will return a 500 error by default. This can be customized when registering support for effectful endpoints.

[HttpGet("{id:guid}")]
public Aff<User> FindUser(Guid id) => _db.FindUserAff(user => user.Id == id);
// => Success(User)  -> 200 Ok(User)
// => Err            -> 500 InternalServerError -or- customized error handler

Your effect can also return action results. This gives you more direct control over the mapping of internal values to results, while still remaining in the monad.

[HttpGet("{id:guid}")]
public Aff<IActionResult> FindUser(Guid id) => 
    _db.FindUserAff(user => user.Id == id)
       .Map(user => new OkObjectResult(user) as IActionResult)
    | @catch(Errors.UserAccountSoftDeleted, _ => SuccessAff<IActionResult>(new NotFoundResult()));
// => Success(User)                 -> 200 Ok(User)
// => Err(UserAccountSoftDeleted)   -> 404 NotFound
// => Err                           -> 500 InternalServerError -or- customized error handler

To customize global effect result handling, pass a Func<Fin<IActionResult>, IActionResult> delegate into your call to AddEffAffEndpointSupport. This delegate is always called when the effect completes. Your delegate should handle both success and failure cases for the effect.

The following example overrides failure handling when the error matches a predefined application error.

Error UserNotFound = Error.New(4009, "User not found");

Func<Fin<IActionResult>, IActionResult> unwrapper = fin => 
    fin.Match(
        identity, 
        error => error switch
        {
            {} err when err == UserNotFound => new NotFoundResult(),
            _ => new StatusCodeResult(StatusCodes.Status500InternalServerError),
        });

services
    .AddMvc()
    .AddLanguageExtTypeSupport()
    .AddEffAffEndpointSupport(unwrapper)

Using Runtimes

You can also return Eff/Aff types which use runtimes from controllers. Doing so requires a little more setup to tell the library how to create the runtime for each call.

We will use the following simple runtime in our examples. For more information on creating and using runtimes, see this wiki article.

// ###########################
// EXAMPLE RUNTIME DEFINITIONS
// ###########################

// Subsystem interface
public interface UsersIO
{
    Task<User> FindUser(Func<User, bool> predicate);
}

// Trait for our subsystem
public interface HasUsers<RT>
    where RT : struct, HasUsers<RT>
{
    Eff<RT, UsersIO> UsersEff { get; }
}

// Convenience functions
public static class Users<RT>
    where RT : struct, HasUsers<RT>, HasCancel<RT>
{
    public static Aff<RT, User> FindUser(Func<User, bool> predicate) =>
        default(RT).UsersEff.MapAsync(async io => await io.FindUser(predicate));
}

// Live runtime definition
public readonly struct Runtime : HasCancel<Runtime>, HasUsers<Runtime>
{
    // omitting implementation
}

When setting up Eff/Aff endpoint support in your startup functions, add a call to the overload which requests a runtimeProvider function. This function will give you an instance of the IServiceProvider in case you need access to any configured services when constructing your runtime.

Each call to AddEffAffEndpointSupport adds support for one more runtime type. Calling the non-generic AddEffAffEndpointSupport adds handlers for the unital runtime (no-runtime Eff<A>), with each generic call setting up support for a different runtime (Eff<RT1, A>, Eff<RT2, A>, etc). If you intend to always use a runtime, you can safely omit the call to the non-generic overload.

Func<Fin<IActionResult>, IActionResult> unwrapper = ... ;

services
    .AddMvc()
    .AddLanguageExtTypeSupport()
    // Adds support for Eff<A> and Aff<A>
    .AddEffAffEndpointSupport(unwrapper)
    // Adds support for Eff<Runtime, A> and Aff<Runtime, A>
    .AddEffAffEndpointSupport<Runtime>(
        unwrapper: unwrapper, // If you have a custom unwrapper function, you should also provide that here
        runtimeProvider: (IServiceProvider p) => new Runtime() // construct a new runtime or provide an existing one
    )
    // Adds support for Eff<OtherRuntime, A> and Aff<OtherRuntime, A>
    .AddEffAffEndpointSupport<OtherRuntime>(
        unwrapper: unwrapper, // If you have a custom unwrapper function, you should also provide that here
        runtimeProvider: (IServiceProvider p) => new OtherRuntime()
    )

Now we can use the runtime type in our controller method.

[HttpGet("{id:guid}")]
public Aff<Runtime, User> FindUser(Guid id) => Users<Runtime>.FindUser(user => user.Id == id);
// => Success(User)  -> 200 Ok(User)
// => Err            -> 500 InternalServerError -or- customized error handler

Option

Returning an Option<T> from a controller method will convert Some to a 200 and return the value in the response and None becomes 404NotFound.

[HttpGet("{id:guid}")]
public Option<User> FindUser(Guid id) => _db.Find(user => user.Id == id);
// => Some(User) -> 200 Ok(User)
// => None       -> 404 NotFound

Minimal APIs

Please note that minimal API endpoint definitions are currently not supported.

The minimal API system uses a completely different model binding approach that does not allow for injectable deserializers. This means that custom deserialization/binding must be defined directly on the type. This would require defining these parsing/binding rules directly in the LanguageExt type definitions, which does not seem like a worthwhile exercise.

Luckily, it seems this limitation is a pain point for others and a feature request to add injectable binding is being tracked in this github issue.

Product Compatible and additional computed target framework versions.
.NET net5.0 is compatible.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on LanguageExt.AspNetCore.NativeTypes:

Package Downloads
LanguageExt.AspNetCore.NativeTypes.NewtonsoftJson

Enables controlling Newtonsoft.Json conversion of LanguageExt types.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last updated
0.2.0 483 10/9/2023
0.0.1 589 6/28/2023

## [0.2.0] - 2023-10-09
     ### Added
     - Added support for configuring this library from IMvcCoreBuild instances
     ### Changed
     - Improved API for configuring features of this library