FSharp.AspNetCore.WebAppBuilder
The webApp
computation expression lets you define ASP.NET Core web applications in a succinct and declarative way, minimizing code, maximizing readability, and providing a simple but thorough set of escape hatches, so you can always drop down to the raw ASP.NET Core APIs when you need to.
Installation
Get it on NuGet: FSharp.AspNetCore.WebAppBuilder.
|
API documentation
See the API documentation here.
Examples
open FSharp.AspNetCore.Builder
Hello, world.
Program.fs
let app =
webApp {
get "/hello" (fun () -> "🌎")
post "/chars" (fun char -> printf $"%c{char}")
delete "/chars" (fun count -> printf $"""{String.replicate count "\b \b"}""")
}
app.Run ()
Progressively enhance your minimal API endpoints with additional metadata and configuration while keeping it simple.
Program.fs
let app =
webApp {
get "/hello" [
Status200OK, typeof<string>
Status404NotFound, null
] (fun () ->
if DateTime.Today.DayOfWeek = DayOfWeek.Monday then Results.NotFound ()
else Results.Ok "🌎"
) (fun routeHandler ->
routeHandler.WithOpenApi (fun op -> op.Description <- "Hello, 🌎 — unless it's Monday."; op)
routeHandler.AllowAnonymous ()
)
}
app.Run ()
Wire up your dependencies in a succinct and declarative way.
Program.fs
let app =
webApp {
connectionString "SqlDb" SqlConnectionString
configurationValue "AppSettings:SqlCommandTimeout" SqlCommandTimeout
singleton Id.NewId
singleton typeof<IDataAccess> typeof<DataAccess>
get "/xs" (fun (db : IDataAccess) -> Results.Ok (db.GetAll ()))
get "/xs/{id}" (fun (db : IDataAccess) id ->
db.TryGet id
|> Option.map Results.Ok
|> Option.defaultWith Results.NotFound)
post "/xs" (fun (db : IDataAccess) newId x ->
let x = db.Create (newId (), x)
Results.Created ($"/xs/{x.Id}", x))
delete "/xs/{id}" (fun (db : IDataAccess) id -> Results.NoContent (db.Delete id))
}
app.Run ()
Easily evolve your code to use Swagger UI, heavyweight controllers, and enable dead simple integration testing, keeping your app's definition clean and simple without giving up the freedom to use arbitrary first- or third-party APIs on the underlying Microsoft.AspNetCore.Builder
constructs.
Program.fs
let app configureBuilder =
webApp {
connectionString "SqlDb" SqlConnectionString
configurationValue "AppSettings:SqlCommandTimeout" SqlCommandTimeout
singleton Id.NewId
singleton typeof<IDataAccess> typeof<DataAccess>
logging (fun logging -> logging.AddConsole ())
services (fun services ->
services.AddEndpointsApiExplorer ()
services.AddSwaggerGen ()
services.AddControllers ())
buildWith configureBuilder
webApp (fun app ->
app.UseSwagger ()
app.UseSwaggerUI ()
app.MapControllers ())
}
(app ignore).Run ()
Tests.fs
[<Property>]
let ``POST /clowns returns 400 Bad Request if the shoe size is too small`` (Bad clown) =
task {
use app = Program.app (fun builder -> builder.WebHost.UseTestServer ())
do! app.StartAsync ()
use client = app.GetTestClient ()
use! badRequest = client.PostAsJsonAsync ("clowns", clown)
Assert.Equal (Status400BadRequest, badRequest.StatusCode)
let! problem = badRequest.Content.ReadFromJsonAsync<ValidationProblemDetails> ()
match Assert.Contains ("shoeSize", problem.Errors) with
| [|_|] -> ()
| unexpected -> failwith $"Expected a single error message but got: %A{unexpected}"
}
|> Async.AwaitTask
|> Async.RunSynchronously
See Examples for some more realistic (and runnable) examples.
The webApp
computation expression
The library includes custom operations for each of the top-level properties of Microsoft.AspNetCore.Builder.WebApplicationBuilder
, one for the builder itself, and one for the Microsoft.AspNetCore.Builder.WebApplication
once it's been built:
configuration
, corresponding toMicrosoft.AspNetCore.Builder.WebApplicationBuilder.Configuration
.logging
, corresponding toMicrosoft.AspNetCore.Builder.WebApplicationBuilder.Logging
.services
, corresponding toMicrosoft.AspNetCore.Builder.WebApplicationBuilder.Services
.host
, corresponding toMicrosoft.AspNetCore.Builder.WebApplicationBuilder.Host
.webHost
, corresponding toMicrosoft.AspNetCore.Builder.WebApplicationBuilder.WebHost
.builder
, corresponding to theMicrosoft.AspNetCore.Builder.WebApplicationBuilder
itself.webApp
, corresponding to theMicrosoft.AspNetCore.Builder.WebApplication
after it's been built.
A few more specialized custom operations are provided to make certain common scenarios more concise, including:
environmentVariables
, for adding environment variables to the app's configuration.jsonFile
(andoptionalJsonFile
), for adding a JSON configuration file to the app's configuration.connectionString
, for adding a strongly-typed connection string to the app's dependency injection container.configurationValue
, for adding a strongly-typed configuration value to the apps' dependency injection container.singleton
, for adding a singleton instance of a dependency to the app's dependency injection container.scoped
, for adding a scoped instance of a dependency to the app's dependency injection container.transient
, for adding a transient instance of a dependency to the app's dependency injection container.hostedService
, for adding a hosted background service to the app's dependency injection container.configure
, for configuring options registered via the "options pattern."
See the API documentation for the full set of supported operations, with examples.
The number of other special cases that could be added is practically infinite, corresponding to the countless first- and third-party .Add*
extension methods and their even-more-countless overloads—scoped
, transient
, serverSideBlazor
… A few more may be added, but it is unlikely to be many.
You can always call any extension method on IServiceCollection
you like inside the services
operation, for example:
services (fun services ->
services.AddScoped<MyScopedService> ()
services.AddHsts ()
services.AddOptions ()
// Etc., etc.
)
The same is true for any of the other top-level properties of Microsoft.AspNetCore.Builder.WebApplicationBuilder
, or the built Microsoft.AspNetCore.Builder.WebApplication
.
Minimal APIs endpoint support
Add endpoints to your web app using custom operations corresponding to the MapGet
, MapPost
, etc., extension methods. Currently supported: get
, post
, put
, delete
, and patch
.
All endpoint operations have overloads that take a (int * Type) list
for specifying which status codes and types the endpoint produces, as well as overloads accepting a function that can be used to further configure the route handler.
open Microsoft.AspNetCore.Http.StatusCodes
let app =
webApp {
get "/hello" (fun () -> "🌎")
get "/clowns"
[Status200OK, typeof<seq<Dtos.Get.Clown>>]
(fun (db : IDataAccess) -> Results.Ok (db.GetAll ()))
post "/clowns" [
Status201Created, typeof<Dtos.Get.Clown>
Status400BadRequest, typeof<ValidationProblemDetails>
Status409Conflict, typeof<string>
Status500InternalServerError, typeof<ProblemDetails>
] (fun (logger : ILogger<Program>) mkId (db : IDataAccess) clown ->
// ...
Results.Created ($"/clowns/{id}", clown)
) (fun routeHandler ->
routeHandler.Accepts (typeof<Dtos.Create.Clown>, MediaTypeNames.Application.Json)
routeHandler.AddFilter<MyEndpointFilter> ()
)
}
Adding your own custom operations to the webApp
builder
If you like, you can always add a custom operation corresponding to any first- or third-party API you choose by extending the WebAppBuilder
yourself:
type WebAppBuilder with
[<CustomOperation("scoped")>]
member _.Scoped (builder : WebApplicationBuilder, serviceType : Type, implementationType : Type) =
ignore <| builder.Services.AddScoped (serviceType, implementationType)
builder
let app =
webApp {
scoped typeof<IScopedService> typeof<ScopedService>
}
Rationale
❔: Why use a computation expression? Why not write an F# wrapper using "functions and data"? I don't see anything monadic or applicative happening here.
Functions and data are absolutely more straightforward, robust, and maintainable than computation expressions with ad hoc custom operations—most of the time. But we're not designing a whole new web framework here: we're just trying to make the extensive functionality of the ASP.NET Core ecosystem a bit nicer to use in F#. There are two main things at play:
-
Method overloads.
The existing set of ASP.NET Core APIs, including the minimal APIs functionality, leans heavily on pervasive method overloading. Trying to wrap every overload in the ever-growing set of first- and third-party APIs using distinctly-named module functions, or else trying to plaster them over using some other kind of data structure, no matter how clever, would be a losing battle.
Custom operations in computation expressions support method overloading while papering over some of the rough edges you run into consuming mutable builder-style APIs in F#.
-
The builder pattern.
Again, APIs using the mutable builder pattern are just not much fun to consume in F#. In the language they were originally designed for, you don't need to explicitly discard the final return value, but in F# you do. Repeatedly. Sometimes 20 times in a single
Program.fs
file.A small amount of cleverness with custom operations (and a
#nowarn "20"
) really does work magic:services (fun s -> s.AddScoped<MyScopedService> ())
The function that the
services
operation (or most of the others) takes can have any return type; it gets magically swallowed, because it doesn't matter. It shouldn't be so satisfying, but it is. Just try it the old way, piping it into|> ignore
every time, and then come back.
namespace Microsoft.FSharp
--------------------
namespace FSharp
val char: value: 'T -> char (requires member op_Explicit)
--------------------
type char = System.Char
val string: value: 'T -> string
--------------------
type string = System.String
type Async = static member AsBeginEnd: computation: ('Arg -> Async<'T>) -> ('Arg * AsyncCallback * obj -> IAsyncResult) * (IAsyncResult -> 'T) * (IAsyncResult -> unit) static member AwaitEvent: event: IEvent<'Del,'T> * ?cancelAction: (unit -> unit) -> Async<'T> (requires delegate and 'Del :> Delegate) static member AwaitIAsyncResult: iar: IAsyncResult * ?millisecondsTimeout: int -> Async<bool> static member AwaitTask: task: Task<'T> -> Async<'T> + 1 overload static member AwaitWaitHandle: waitHandle: WaitHandle * ?millisecondsTimeout: int -> Async<bool> static member CancelDefaultToken: unit -> unit static member Catch: computation: Async<'T> -> Async<Choice<'T,exn>> static member Choice: computations: Async<'T option> seq -> Async<'T option> static member FromBeginEnd: beginAction: (AsyncCallback * obj -> IAsyncResult) * endAction: (IAsyncResult -> 'T) * ?cancelAction: (unit -> unit) -> Async<'T> + 3 overloads static member FromContinuations: callback: (('T -> unit) * (exn -> unit) * (OperationCanceledException -> unit) -> unit) -> Async<'T> ...
--------------------
type Async<'T>
static member Async.AwaitTask: task: System.Threading.Tasks.Task<'T> -> Async<'T>
val seq: sequence: 'T seq -> 'T seq
--------------------
type 'T seq = System.Collections.Generic.IEnumerable<'T>
type CustomOperationAttribute = inherit Attribute new: name: string -> CustomOperationAttribute member AllowIntoPattern: bool with get, set member IsLikeGroupJoin: bool with get, set member IsLikeJoin: bool with get, set member IsLikeZip: bool with get, set member JoinConditionWord: string with get, set member MaintainsVariableSpace: bool with get, set member MaintainsVariableSpaceUsingBind: bool with get, set member Name: string
--------------------
new: name: string -> CustomOperationAttribute