.NET 6 Minimal API with F# and Giraffe
Here's my setup for an F# .NET 6 minimal API.
Start by opening up the main modules we'll be using in Startup.fs
(or Program.fs
as I tend to name it).
open System
open System.Text
open Dapper
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Hosting
open Microsoft.AspNetCore.Authentication.JwtBearer
open Microsoft.Extensions.Hosting
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Logging
open Microsoft.IdentityModel.Tokens
open Microsoft.AspNetCore.Cors
open Giraffe
Application Bootstrap
Then bootstrap the .NET minimal API in the main
entry point function.
[<EntryPoint>]
let main _ =
Host
.CreateDefaultBuilder()
.ConfigureWebHostDefaults(fun webHostBuilder ->
webHostBuilder
.Configure(configureApp)
.ConfigureServices(configureServices)
.ConfigureLogging(configureLogging)
|> ignore)
.Build()
.Run()
0
Configuring the App
In the configureApp
function, we set the following, along with anything additional you want the web app to use.
- A default error handler for Giraffe
- The API base route
- JWT authentication
- Cors (allowing the applicable origins)
- Giraffe, by passing it all routes
let configureApp (app: IApplicationBuilder) =
app.UseGiraffeErrorHandler errorHandler |> ignore
app.UsePathBase("/api") |> ignore
app.UseAuthentication() |> ignore
app.UseCors(
Action<_>
(fun (b: Infrastructure.CorsPolicyBuilder) ->
b.AllowAnyHeader() |> ignore
b.AllowAnyMethod() |> ignore
b.AllowAnyOrigin() |> ignore)
)
|> ignore
app.UseGiraffe webApp
Where error handler is a function that takes an exception and logger and returns the exception message along with the default status code of your choice, like follows:
let errorHandler (ex: Exception) (logger: ILogger) =
logger.LogError(EventId(), ex, "An error occurred when processing the request")
clearResponse
>=> setStatusCode 400
>=> text ex.Message
And where webApp
, which is passed to app.UseGiraffe
is the main Giraffe routing function, example:
let webApp: HttpFunc -> HttpContext -> HttpFuncResult =
choose [ GET
>=> choose [ route "/ping" >=> authenticate >=> text "pong" ]
A few notes on the Giraffe web app:
What I tend to do is pull all routes out into a single file rather than keep them in the main entry point file. Then I have that file import the appropriate handlers, which are organized in files by data type. For instance, if I have 10 routes that all deal with User and 1o that deal with Product, I import those two modules and have have api/user/...
use handler functions from the user handlers module, and api/product/...
use handler functions from the produce handlers module.
Configuring Services
Next define the configureServices
function which is referenced in the main
function. That will look like this:
let configureServices (services: IServiceCollection) =
registerTypeHandlers ()
services
.AddCors()
.AddAuthentication(fun opt -> opt.DefaultAuthenticateScheme <- JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(fun (opt: JwtBearerOptions) ->
opt.TokenValidationParameters <-
TokenValidationParameters(
IssuerSigningKey = SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
ValidIssuer = issuer,
ValidAudience = audience
))
|> ignore
services.AddGiraffe() |> ignore
services.AddAWSLambdaHosting(LambdaEventSource.RestApi) |> ignore
In here we do the following:
- Register type handlers (more on this in a minute)
- Add Cors, which we we have already configured the app to use
- Add authentication, which should use the
Microsoft.AspNetCore.Authentication.JwtBearer
module which we've already opened. Additionally, the API will need to utilizeSystem.IdentityModel.Tokens.Jwt
andSystem.Security.Claims
which won't be covered here. - Add the Girraffe service
- Add AWS Lambda hosting (this single line enables the app to be served as a serverless application in AWS - can be removed if this is a traditional server).
The main thing to note here is type handlers. Most of the things we register here are typical services, but with F# we may want to register type handlers for the following situations:
- If we are using Dapper.Fsharp, which needs to map
null
tooption
- If we are generating Guids in the API for SQL primary keys (rather than auto-incrementing an integer for IDs in SQL)
If either of those cases are true (or you want other type conversions to happen between SQL and the API), then we can define the registerTypeHandlers
function like this:
let registerTypeHandlers () =
Dapper.FSharp.OptionTypes.register()
SqlMapper.AddTypeHandler(MySqlGuidTypeHandler())
SqlMapper.AddTypeHandler(MySqlGuidOptionTypeHandler())
SqlMapper.RemoveTypeMap(typedefof<Guid>)
SqlMapper.RemoveTypeMap(typedefof<Guid Option>)
Where:
Dapper.FSharp.OptionTypes.register()
is a function provided by Dapper.Fsharp. (If using Dapper.Fsharp).
And where the two type handlers we've manually defined are:
type MySqlGuidTypeHandler() =
inherit SqlMapper.TypeHandler<Guid>()
override _.SetValue(param, value) = param.Value <- value |> string
override _.Parse value = value |> string |> Guid
type MySqlGuidOptionTypeHandler() =
inherit SqlMapper.TypeHandler<Guid Option>()
override _.SetValue(param, value) =
match value with
| None -> param.Value <- null
| _ -> param.Value <- value |> string
override _.Parse value =
match value with
| null -> None
| _ -> Some(value |> string |> Guid)
The above will allow us to enforce the generation of proper guids in the API if we are using guids for primary keys.
Configuring Logging
Finally, the last step in the application bootrap from the main
function is to configure logging, which is as follows:
let configureLogging (builder: ILoggingBuilder) =
let filter (l: LogLevel) = l.Equals LogLevel.Error
builder.AddFilter(filter).AddConsole().AddDebug()
|> ignore
Loose Ends
This is essentially what is needed to have a minimal API in F# and Giraffe, along with authentication and SQL mapping - which most real applications will need. What's not shown here is setting up the authentication itself, which is done via the .NET library for generating and validating JWTs.
Other things that are not covered here, which I tend to use in my APIs are 1) using a dotenv style environment variable setup, and 2) setting up a DB connection in more of a functional style instead of via .NET's dependency injection. I will cover these in separate posts.
Full Code Example
module Api
open System
open System.Text
open Dapper
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Hosting
open Microsoft.AspNetCore.Authentication.JwtBearer
open Microsoft.Extensions.Hosting
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Logging
open Microsoft.IdentityModel.Tokens
open Microsoft.AspNetCore.Cors
open Giraffe
open Auth
open AuthConstants
open Routes
// API Routes
let webApp = routes
let errorHandler (ex: Exception) (logger: ILogger) =
logger.LogError(EventId(), ex, "An error occurred when processing the request")
clearResponse
>=> setStatusCode 400
>=> text ex.Message
let configureLogging (builder: ILoggingBuilder) =
let filter (l: LogLevel) = l.Equals LogLevel.Error
builder.AddFilter(filter).AddConsole().AddDebug()
|> ignore
let configureApp (app: IApplicationBuilder) =
app.UseGiraffeErrorHandler errorHandler |> ignore
app.UsePathBase("/api") |> ignore
app.UseAuthentication() |> ignore
app.UseCors(
Action<_>
(fun (b: Infrastructure.CorsPolicyBuilder) ->
// Put real allowed origins in here
b.AllowAnyHeader() |> ignore
b.AllowAnyMethod() |> ignore
b.AllowAnyOrigin() |> ignore)
)
|> ignore
app.UseGiraffe webApp
type MySqlGuidTypeHandler() =
inherit SqlMapper.TypeHandler<Guid>()
override _.SetValue(param, value) = param.Value <- value |> string
override _.Parse value = value |> string |> Guid
type MySqlGuidOptionTypeHandler() =
inherit SqlMapper.TypeHandler<Guid Option>()
override _.SetValue(param, value) =
match value with
| None -> param.Value <- null
| _ -> param.Value <- value |> string
override _.Parse value =
match value with
| null -> None
| _ -> Some(value |> string |> Guid)
let registerTypeHandlers () =
Dapper.FSharp.OptionTypes.register()
// So Dapper can accept strings IDs from DB for GUID fields
SqlMapper.AddTypeHandler(MySqlGuidTypeHandler())
SqlMapper.AddTypeHandler(MySqlGuidOptionTypeHandler())
SqlMapper.RemoveTypeMap(typedefof<Guid>)
SqlMapper.RemoveTypeMap(typedefof<Guid Option>)
let configureServices (services: IServiceCollection) =
registerTypeHandlers ()
services
.AddCors()
.AddAuthentication(fun opt -> opt.DefaultAuthenticateScheme <- JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(fun (opt: JwtBearerOptions) ->
opt.TokenValidationParameters <-
TokenValidationParameters(
IssuerSigningKey = SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
ValidIssuer = issuer,
ValidAudience = audience
))
|> ignore
services.AddGiraffe() |> ignore
services.AddAWSLambdaHosting(LambdaEventSource.RestApi) |> ignore
[<EntryPoint>]
let main _ =
Host
.CreateDefaultBuilder()
.ConfigureWebHostDefaults(fun webHostBuilder ->
webHostBuilder
.Configure(configureApp)
.ConfigureServices(configureServices)
.ConfigureLogging(configureLogging)
|> ignore)
.Build()
.Run()
0