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. NewtonsoftJsonsupport will be added eventually.
- Currently, there is only support for
- Transparent
Monad -> IActionResultmapping 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.
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
})
;
});The following types are supported in controllers as input arguments.
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);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);In addition to allowing LangExt types in controller arguments, these types can be returned in JSON return types with configurable representations.
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.
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
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
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.
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 handlerYour 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 handlerTo 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)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 handlerReturning 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 NotFoundPlease 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.