.NET 6 Minimal API with F# and Giraffe

.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.

  1. A default error handler for Giraffe
  2. The API base route
  3. JWT authentication
  4. Cors (allowing the applicable origins)
  5. 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:

  1. Register type handlers (more on this in a minute)
  2. Add Cors, which we we have already configured the app to use
  3. Add authentication, which should use the Microsoft.AspNetCore.Authentication.JwtBearer module which we've already opened. Additionally, the API will need to utilize System.IdentityModel.Tokens.Jwt and System.Security.Claims which won't be covered here.
  4. Add the Girraffe service
  5. 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 to option
  • 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