So, you wanna learn F Sharp? And you wanna do so by building a key-value store, served via a .NET 6.0 minimal API? Then this is the perfect post for you ^_^

By the time we finish this post, we'll have built a web API that's kinda like Redis, with F# (we'll call it Fedis). We'll be able to post key-value pairs written in JSON, or plain strings through url parameters. We'll also be able to lookup the value of keys in the store. Lastly, we'll make a couple endpoints for managing the store itself — querying all the data, and purging all the data.

This tutorial assumes a basic understanding of the command line/terminal, HTTP verbs, and JSON structuring. If you get lost along the way, pop open a footnote thingy, or use your search engine of choice for a bit more background.

If you get really lost and feel that this guide could use a bit more explaining, shoot me a DM on Twitter (@imjustlilith) and I'll make this post better, while thanking you profusely.

Let us begin.

Getting Started

Let's start by installing .NET 6.0. Head on over to Microsoft's web page for downloading .NET 6.0 and choose the installer or binary appropriate for your system. Download it, install .NET, and crack open a terminal.

In your terminal, choose a directory to hold your project (like /home/$username/0projects/fedis, or $username\Documents\0projects\fedis). Go ahead and run:

dotnet new web -lang F#

That'll turn your directory into a project directory, plus initialize a new empty web server (Program.fs).

If you take a look at the directory structure, you'll see something like the following screenshot:

New F# project folder structure.

Next, open your editor of choice (for me, VS Code + the Ionide extension, which I highly recommend). Go ahead and open Program.fs, and we'll explore a simple F# program.

F# Syntax Crash Course

New F# project code.

Lines 1 to 3 start with open, which means they're import declarations. These tell the F# compiler to use functions from other namespaces or modules. If you're coming from C#, they're like the using declarations.

Line 5 is an example of an attribute. A lot has been written elsewhere about attributes, but the short version is that attributes give the compiler some extra information about what you're doing. In this example, we have the [<EntryPoint>] attribute, which tells the compiler where to start executing code when the program is run.

Lines 6 onward contain our actual program — a small function. Let's take this apart, too.

Kinda like other languages, we start with a main function, named main.

let is the keyword that defines things: a function, a variable, you name it. Also like other languages, arguments to functions follow the function itself. Here, we define those neatly as args.

On line 7, we have an example of an object method: WebApplication.CreateBuilder(args). And yes, it's using the same args we declared above.

We'll briefly skip over line 10 to talk about lines 12 and 14. Line 12 is a blocking call; that means that we won't get to line 14 until it's done. And what does it do? It runs our app. app.Run() is arguable the most important part of our API; when we start it up, it'll keep running, until we close it. And when we close it, line 14 is executed, which returns 0. (F# doesn't use the return that you may find in other languages.)

Now, let's talk about line 10, cause there's a lot to unpack there.

Line 10 starts with a method call to our app variable. MapGet is a function that adds a route that's accessible via a GET request. (Likewise, .MapPost would add a route accessible with a POST request.) The first argument to the call is the endpoint (in this case, the web root, or, /). The second argument is the function that will handle requests made to that import.

Func<string>(fun () -> "Hello World!")) |> ignore

Func<string> is the beginning of a delegate (a function call treated like an object). This tells the function call that a function will return a string.

(fun () -> "Hello World!")) is an anonymous function; it's a lambda expression, meaning, an unnamed function executed inline.

fun () means that the function takes no arguments.

-> is how you separate arguments from expressions in F#.

"Hello World!" is the string that's returned.

|> ignore is how you discard the output of a function.

Putting that all together, we're defining a string response to any GET request executed against our API server root — and that string is "Hello World!" :)

Extending Our Program

Let's go ahead and run our API, and see what happens. Crack open a terminal and execute the following:

dotnet run

In just a moment, our API will begin to run, with an unsecured (http://) address on port 5230 or something. Open http://localhost:[that port number] and you should see this:

Ayyy our API works; this screenshot is proof.

Look at what we did! It's our API, happily rumbling along.

Let's give it some quirks and features.

We'll start by adding a new line, below line 10, that looks an awful lot like it:

app.MapGet("/api/v1.0/get/{item}", Func<string,string>(fun item -> get item) ) |> ignore

Just like line 10, we've added a handler for GET requests, but this time, on a different route. We've also added a new function, get, that does... something. I say "something" because we haven't defined the function yet. F# doesn't know what get means. Let's tell it, by making that function in another file.

Create a new file next to our program file and call it, idk, Endpoints.fs. It doesn't matter what you call it as long as it ends in .fs; that's how the compiler knows it's an F# file. Additionally, open the file ending in .fsproj and add a new reference to the file we just made, above the current reference to Program.fs. Your fsproj file should look something like this now:

fsproj file with two references.

Once that's done, let's add some changes to Endpoint.fs. We'll start by declaring a namespace; a namespace lets us group modules and functions together. Remember the open declarations from earlier? Each of those references a namespace, and then a module or namespace within that namespace, and so on. I'm gonna call my namespace l6; you can call yours whatever you want. The important thing is that we remember it for later.

Next, let's define a new module — Endpoints — and define a function — get. For now, let's make it return its input.

Finally, we'll go back to Program.fs and add a new open declaration. This time, we'll open our new namespace and module. See?

Our endpoints file above our program file.

Close the server by hitting Ctrl+C in the terminal window, then restart it to apply the changes we made. Navigate to http://localhost:[that port number]/api/v1.0/get/Surprise! and you should see this:

Web browser showing the text 'Surprise!'

Aw yiss. It's all coming together.

So far, we've looked at a few features of F#, mostly regarding its syntax. Let's take what we've learned and keep building our API.

Further Expansion

For the sake of brevity, I'll ask you to copy and paste a couple of code blocks.

Replace everything in Program.fs with the following (and I do mean everything!):

open System
open Microsoft.AspNetCore.Builder
open Microsoft.Extensions.Hosting
open Microsoft.Extensions.Logging
open L6.Endpoints

let main args =
    let builder = WebApplication.CreateBuilder(args)
    let app = builder.Build()

    let logger:ILogger = app.Logger

    app.MapGet("/api/v1.0/get/{item}", Func<string,string>(fun item -> get item) ) |> ignore
    app.MapGet("/api/v1.0/add/{key}={value}", Func<string, string, string> (fun key value -> add (key, value, logger) ) ) |> ignore
    app.MapPost("/api/v1.0/add/", Func<_,_> (fun body -> addPost body logger)) |> ignore
    app.MapGet("/api/v1.0/del/{item}", Func<string,string>(fun item -> del item) ) |> ignore
    app.MapGet("/api/v1.0/purge", Func<string,string> purge ) |> ignore
    app.MapGet("/api/v1.0/contents", Func<string,string> contents ) |> ignore


    0 // Exit code

Also, replace everything in Endpoints.fs with the following:

namespace L6

open FSharp.Json
open System.Text.Json
open Microsoft.Extensions.Logging
open Microsoft.AspNetCore.Http

module Endpoints =
  let mutable store = 
    Map [ ("hello", "world") ]
  let get a =
      let _, resp = store.TryGetValue(a)
      if isNull resp
      then "Not Found"
      else resp
      | error -> error.ToString()
  let add (a:string, b:string, logger:ILogger) : string =
      store <- store.Add (a,b)
      | error -> error.ToString()

  let addPost (body:JsonElement) (logger:ILogger) =
      let newBody = body.Deserialize<Map<string,string>>()
      let keys = newBody.Keys
      let values = newBody.Values
      for key in keys do
        store <- store.Add(key.ToString(),newBody.Item(key).ToString())
    | error -> error.ToString()

  let del a =
    store <- store.Remove(a)
    let res, _ = store.TryGetValue(a)
    if res
    then "Error: Value not removed! 👀"
    else "OK"

  let purge a =
    store <- Map[]

  let contents a =
    let mutable keys = store.Keys

Wow, that was a lot. Can you tell what everything above does? Let's recap a bit.

We've added some more routes to our API, including a new method (POST). We've defined some new functions, too, that handle those routes. We've added some logging functionality by opening a new namespace... which reminds me. We haven't installed the FSharp.Json package yet. If we tried to run our APi again, we'd get an error. I mean, look:

Terminal showing a large red error message.

Let's install the FSharp.Json package. In a terminal, run this (and make sure the terminal's current directory is your project root):

dotnet add package FSharp.Json

When we run our API again, it'll work this time ^_^.

So, what does it do?

A Fedis API Overview

In short, a lot. We added new routes; I'll define them now.

/api/v1.0/get/{item}: This looks up a key in the store, and returns its value.

/api/v1.0/add/{key}={value}: This adds a key to the store with the given value.

/api/v1.0/add/: This does the same, but as a POST request, rather than a GET request. This means that we can POST data as JSON.

/api/v1.0/del/{item}: Here's an endpoint that deletes the data in the store referenced by a given key.

/api/v1.0/purge: This deletes ALL of the data in the store!

/api/v1.0/contents: Lastly, this lists all of the data in the store.

Try to play with the endpoints! You'll see that as you add key-value pairs to the store, and call the contents API, that our store grows and grows.

That's about it ^_^

So... What's the Point?

Whatever you want it to be. At the most basic level, it's a fun little project that'll get your feet wet with F#. However, if you slap some authentication on the endpoints, you could definitely use this as a sort of Redis cluster, on a micronized, volatile scale.

This little API doesn't even touch on things like bouncing the data to disk (to prevent data loss in case of power failure or crashing), or sharding (replicating the data to other APIs to increase availability/lower latency), or alternate network protocols (it would be really, really fast with gRPC or protobufs). That's all left to you. This API can be the basis for exploration.

How do you do all of those things in F#? What challenges will you face while doing them? What more is there to learn?

However you decide to use this, I hope we've learned a few new things.

Happy hacking! 💙