.NET 6 Minimal apis for F# devs, what we get? (including testing)

Dec 30, 2021 min read

So with .NET 6 we have received loudly announced minimal apis. Well... I would name them normal APIs (I am looking 👀 on express.js ...) but let's put the sarcasm aside and lets see how it could improve API development for F# developers.


This post is part of the F# Advent Calendar 2021. Special thanks to Sergey Tihon for organizing this! Check out all the other great posts there!.

If You are “show me the code” guy, You can just go and see the repo here: https://github.com/marcingolenia/minimal-apis-fsharp. But I invite You to read the full post.

So! I saw how easy it should be to create API using the new “Minimal API” model brought to live in .NET 6. Let’s go and check. No powerful IDE is required, we won’t use any templates. Just bash and VSCode, but a notepad or nano will do.

Let’s build the simplest API

Let’s create the folder structure and 2 files - fsproj and Program.fs that will run our API:

1mkdir -p MinApi/MinApi && cd "$_"
2touch MinApi.fsproj Program.fs

Did You know that You can create directory hierarchy by specyfying -p argument? Handy, use it. The “&_” thing is a special thing which holds argument from previous command. Handy, use it.

Let’s pick up the proper SDK nad select the framework version in the project file:

1<Project Sdk="Microsoft.NET.Sdk.Web">
2    <PropertyGroup>
3        <TargetFramework>net6.0</TargetFramework>
4    </PropertyGroup>
5    <ItemGroup>
6        <Compile Include="Program.fs" />
7    </ItemGroup>

Easy. No templates or generated projects are needed right? Time to write some code with the help of MSDN documentation. This should work:

1open Microsoft.AspNetCore.Builder
2open System
4let builder = WebApplication.CreateBuilder()
5let app = builder.Build()
7app.MapGet("/", Func<string>(fun () -> "Hello World!")) |> ignore

Now… that is concise isn’t it? Let’s try to run it:

 1dotnet run
 2info: Microsoft.Hosting.Lifetime[14]
 3      Now listening on: http://localhost:5000
 4info: Microsoft.Hosting.Lifetime[14]
 5      Now listening on: https://localhost:5001
 6info: Microsoft.Hosting.Lifetime[0]
 7      Application started. Press Ctrl+C to shut down.
 8info: Microsoft.Hosting.Lifetime[0]
 9      Hosting environment: Production
10info: Microsoft.Hosting.Lifetime[0]
11      Content root path: /home/marcin/projects/MinApi/
12info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
13      Request starting HTTP/1.1 GET http://localhost:5000/ - -
14info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
15      Executing endpoint 'HTTP: GET / => Invoke'
16info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
17      Executed endpoint 'HTTP: GET / => Invoke'
18info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
19      Request finished HTTP/1.1 GET http://localhost:5000/ - - - 200 - text/plain;+charset=utf-8 32.5680ms

Cool! You can see in the logs that it worked (I’ve hit the endpoint).

What makes me anxious… is the Func up there. We cannot simply pass fsharp function as a parameter in the route mapping, we have to convert it to C# Func. It is easy to do, but do we have to? I really like Giraffe because of its simplicity and F# friendly programming model (Kleisli composition aka fish operator). Let me show You how to use it with minimal APIs and get rid off Func casting.

let’s add Giraffe to the project file:

 1<Project Sdk="Microsoft.NET.Sdk.Web">
 2    <PropertyGroup>
 3        <TargetFramework>net6.0</TargetFramework>
 4    </PropertyGroup>
 5    <ItemGroup>
 6       <PackageReference Include="Giraffe" Version="5.0.0" />
 7    </ItemGroup>
 8    <ItemGroup>
 9        <Compile Include="Program.fs" />
10    </ItemGroup>

By mentioning that I love Giraffe because of the simplicity, I had mainly in mind the fact that Giraffe is just a middleware that runs on the request. So to plug it in it is enough to do this (works with current Giraffe version, You don’t have to wait for Giraffe 6 - there is alpha 2 on nuget at the moment of writing):

 1open Microsoft.AspNetCore.Builder
 2open Giraffe
 4let webApp =
 5    choose [ route "/ping" >=> text "pong"
 6             route "/" >=> text "Hello World!" ]
 8let app = WebApplication.CreateBuilder().Build()
 9app.UseGiraffe webApp
10app.MapGet("csharp/", Func<string>(fun () -> "Hello World!")) |> ignore

Since Giraffe is a middleware, It can coexist with the “native” netcore endpoints routes. It is handy when You want to introduce F# to C# solution, so You can host F# Giraffe API together with the one written in C# by pluggin in the middleware. I have an example of similar thing here: https://github.com/marcingolenia/painless_giraffe_with_csharp_netcore (.NET 5.0 but You will get the idea). For the new stuff we don’t want this mix, remove that line!

 1open Microsoft.AspNetCore.Builder
 2open Giraffe
 4let webApp =
 5    choose [ route "/ping" >=> text "pong"
 6             route "/" >=> text "Hello World!" ]
 8let app = WebApplication.CreateBuilder().Build()
 9app.UseGiraffe webApp

Better? I like it more. It should still work.

But how to test minimal APIs?

There is one trick You have to do, let me show You. First lets create tests fsproj

2dotnet new sln
3mkdir MinApi.Tests
4touch MinApi.Tests/MinApi.Tests.fsproj
5touch MinApi.Tests/Tests.fs

Infrastructure components, such as the test web host and in-memory test server (TestServer), are provided by the Microsoft.AspNetCore.Mvc.Testing package. This is minimal xml stuff You have to put in the fsproj to make a test project including the package:

 1<Project Sdk="Microsoft.NET.Sdk">
 2  <PropertyGroup>
 3    <TargetFramework>net6.0</TargetFramework>
 4    <GenerateProgramFile>true</GenerateProgramFile>
 5  </PropertyGroup>
 6  <ItemGroup>
 7    <Compile Include="TestApi.fs" />
 8    <Compile Include="Tests.fs" />
 9  </ItemGroup>
10  <ItemGroup>
11    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
12    <PackageReference Include="xunit" Version="2.4.1" />
13    <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="6.0.1" />
14    <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" />
15  </ItemGroup>
16  <ItemGroup>
17    <ProjectReference Include="..\MinApi\MinApi.fsproj" />
18  </ItemGroup>

Time to add projects to sln using two simple commands:

1dotnet sln add MinApi/MinApi.fsproj
2dotnet sln add MinApi.Tests/MinApi.Tests.fsproj

In the past we could use IWebHostBuilder to pass functions that can configure our test host. For example, the Program.cs or App module could look like this:

 1module App =
 2  // open ...  skipped because of verbosity 
 4  let configureApp (app: IApplicationBuilder) =
 5    let env = app.ApplicationServices.GetService<IWebHostEnvironment>()
 6    app.UseGiraffeErrorHandler(errorHandler)
 7      .UseHttpsRedirection()
 8      .UseStaticFiles()
 9      .UseGiraffe(HttpHandler.router)        
10  let configureServices (services: IServiceCollection) = services.AddGiraffe() |> ignore

then we can call configureApp/configureServices to setup TestServer.

1let selfHosted =
2  WebHostBuilder()
3    .UseTestServer()
4    .Configure(Action<IApplicationBuilder>(App.configureApp))
5    .ConfigureServices(App.configureServices)

or alternatively we could point to a module (or class in C#) and do it like this;

1let webBuilder = WebHostBuilder()
3lettestServer = new TestServer(webBuilder)

Now there is no module or configure methods. Same for C# - there is no class. So … how can we set up the TestServer?

Turns out, that during the build, the Program class is generated for us, but it is not visible before build, so the compilation will crash. This means that this stuff won’t work (see [1] for some instructions on how to do integration testing in C#):

1module TestApi 
3    open Microsoft.AspNetCore.Mvc.Testing
5    let create () = (new WebApplicationFactory<Program>()).Server

When we run dotnet test We get the error message:

1Lookup on object of indeterminate type based on information prior to this program point. A type annotation may be needed prior to this program point to constrain the type of the object. This may allow the lookup to be resolved.F# Compiler72
2The type 'Program' is not defined.F# Compiler39

What now? Well, turns out that we can pretend that the Program class is there. See the last line:

 1open Microsoft.AspNetCore.Builder
 2open Giraffe
 4let webApp =
 5    choose [ route "/ping" >=> text "pong"
 6             route "/" >=> text "Hello world" ]
 8let app = WebApplication.CreateBuilder().Build()
 9app.UseGiraffe webApp
12type Program() = class end

According to MSDN [1] we can do what I’ve just described or expose internals to test project by adding this to csproj:

2     <InternalsVisibleTo Include="MyTestProject" />

However it didn’t work for F#. If You can do it let me know in the comments! I would love to use it, instead of empty Program class in my code.

Ok, we have a small function that brings our TestServer up, lets use it in the test:

 1module Tests
 3open Xunit
 4open FSharp.Control.Tasks
 5open TestApi
 8let ``/ should return "Hello world"`` () =
 9    task {
10        let api = runTestApi().CreateClient()
11        let! response = api.GetAsync "/"
12        let! responseContent = response.Content.ReadAsStringAsync()
13        Assert.Equal("Hello world", responseContent)
14    }

You can run the test using dotnet test command, or dotnet watch test for continous execution. The test should pass.

Where to go from here?

We have built “Hello world” here, but it should work with complex API as well. I already introduced such tests and minimal API in my former company. It gets the job done. You my find these extension methods handy:

 1type HttpClient with
 3    member this.Put (path: string) (payload: obj) =
 4        let json = JsonConvert.SerializeObject payload
 6        use content =
 7            new StringContent(json, Text.Encoding.UTF8, "application/json")
 9        this.PutAsync(path, content) |> Async.AwaitTask
11    member this.Post (path: string) (payload: obj) =
12        let json = JsonConvert.SerializeObject payload
14        use content =
15            new StringContent(json, Text.Encoding.UTF8, "application/json")
17        this.PostAsync(path, content) |> Async.AwaitTask
19    member this.Get<'a>(path: string) =
20        this.GetAsync(path)
21        |> Async.AwaitTask
22        |> Async.bind
23            (fun resp ->
24                resp.Content.ReadAsStringAsync()
25                |> Async.AwaitTask
26                |> Async.map JsonConvert.DeserializeObject<'a>)
28    member this.GetString(path: string) =
29        this.GetStringAsync(path) |> Async.AwaitTask

so the test we wrote could look like this:

2let ``/ should return "Hello world"`` () =
3    task {
4        let api = runTestApi().CreateClient()
5        let! response = api.GetString "/"
6        Assert.Equal("Hello world", response)
7    }

You should also consider more F# friendly assertion library. I Love FsUnit, Expecto is also cool (awesome failed assertion messages). If You need to build complex API I advise You to move away from dependency injection and the whole “IServiceCollection” stuff in sake for composition. You may want to check my another post on this: https://mcode.it/blog/2020-12-11-fsharp_composition_root/. If You read it keep in mind one thing; I tend to do Inflexible composition root now and;

  1. For dependencies that I own (ie DB, Rabbit broker etc) I run the dependencies using docker.
  2. For dependencies that I don’t own (ie other team service, salesforce, etc) I build simple mocks. Depending on the environment (dev or prod) I compose real stuff or mocked one. This has a nice benefit; I am able to use 100% of my service locally.


We’ve built a simple API using simple tools - 0 generated projects using IDEs, 0 templates, 100% code which we control and understand. I hope You’ve also learned some bash tricks. Now! Let’s compare this (I’ve removed the empty Program class in sake of fair comparison):

1open Microsoft.AspNetCore.Builder
2open Giraffe
4let webApp =
5    choose [ route "/" >=> text "Hello world" ]
7let app = WebApplication.CreateBuilder().Build()
8app.UseGiraffe webApp

to express.js equivalent:

 1const express = require('express')
 2const app = express()
 4app.get('/', (req, res) => {
 5  res.send('Hello World!')
 8app.listen(5000, () => {
 9  console.log(`Example app listening at http://localhost:${port}`)

This is normal since many many years for node.js developers. I am happy that .NET ecosystem has gained a very lean and quick way to start building an API, like node.js devs have been doing. All in all I hope You share my opinion that the minimal apis, did improve F# web dev-ex as well. Remember, despite of the simple default host model in minimal API, You still have the power to adjust the host, service collection, error handling, logging etc. Benefit from simplicity now, configure later.

In addition I hope that the trick I mentioned (including sample repo) on how to integrate F# Giraffe stuff into existing C# WebApi will help You out there in bringing F# to Your company.

[1] MSDN Integration tests in ASP.NET Core