Introduction to Web Programming in F# with Giraffe – Part 2

Introduction

In this series we are investigating web programming with Giraffe and the Giraffe View Engine plus a few other useful F# libraries.

In this post, we will creating a simple API. In later posts, we will delve a lot deeper into Giraffe but this will be a gentle but useful start.

If you haven't already done so, read the first post in this series.

Getting Started

Ideally, you should have .NET 5 SDK installed. If you haven't, it will still work for .NET 3.1 SDK.

I suggest that you use VSCode with the ionide F# extension. They work on Windows, MacOS and Linux.

Create a new folder called GiraffeApi and open it in VSCode.

Using the Terminal in VSCode type in the following command to create the project:

dotnet new console -lang F#

After a few seconds, the ionide extension will spring to life. When it does, add the following NuGet packages from the terminal:

dotnet add package Giraffe -v 5.0.0-rc-6

open Program.fs and replace the code with the code from this gist:

https://gist.github.com/ianrussellsoftwarepark/8b02b1e07c65e956d921eac882d08f2b

Running the Sample Code

In the Terminal, type the following to run the project:

dotnet run

Go to your browser and type in the following Url:

https://localhost:5001

You should see some text.

Now try the following Uri and you should see some Json returned:

https://localhost:5001/api

You will need a tool to run HTTP calls (GET, POST, PUT, and DELETE). I use Postman but any tool including those available in the IDEs will work.

Our Task

We are going to create a simple API that we can view, create, update and delete Todo items.

Data

Rather than work against a real data store, we are going to create a simple store with a dictionary and use that in our handlers.

Create a new file above Program.fs called TodoStore.fs and add the following code to it:

module Todos

open System
open System.Collections.Concurrent

type TodoId = Guid

type NewTodo = {
    Description: string
}

type Todo = {
    Id: TodoId
    Description: string
    Created: DateTime
    IsCompleted: bool
}

type TodoStore() =
    let data = ConcurrentDictionary<TodoId, Todo>()

    member _.Create todo = data.TryAdd(todo.Id, todo)
    member _.Update todo = data.TryUpdate(todo.Id, todo, data.[todo.Id])
    member _.Delete id = data.TryRemove id
    member _.Get id = data.[id]
    member _.GetAll () = data.ToArray()

This is not production code. TodoStore is a simple class type that wraps a concurrent dictionary that we can test our API out with. It will not persist between runs. To plug it in, we need to make a change to configureServices in Program.fs:

let configureServices (services : IServiceCollection) =
    services.AddGiraffe()
            .AddSingleton<TodoStore>(TodoStore()) |> ignore

We add the TodoStore as a singleton as we only want one instance to exist. If you're thinking that this looks like dependency injection, you would be correct; It is!

You'll need to add an open Todos to the top of the file as well.

Routes

We saw in the last post that Giraffe uses individual route handlers, so we need to think about how to add our new routes.

If we request GET /api/fred from the following routing:

let webApp =
    choose [
        GET >=> route "/" >=> htmlView todoView
        subRoute "/api"
            (choose [
                GET >=> route "" >=> json { Response = "ToDo List API" }
                GET >=> routef "/%s" sayHelloNameHandler
            ])
        setStatusCode 404 >=> text "Not Found"
    ]

We will hit the route that calls the sayHelloNameHandler handler. If we call a POST, no routes will match, so we will fall through to the last line and return a 404 - Not found.

The routes we need to add are:

GET /api/todo // Get a list of todos
GET /api/todo/id // Get one todo
PUT /api/todo // Create a todo
POST /api/todo // Update a todo
DELETE /api/todo/id // Delerte a todo

Let's create our routes with the correct HTTP verbs:

let apiTodoRoutes : HttpHandler =
    subRoute "/todo"
        (choose [
            GET >=> choose [
                routef "/%O" Handlers.viewTaskHandler
                route "" >=> Handlers.viewTasksHandler
            ]
            POST >=> route "" >=> Handlers.updateTaskHandler
            PUT >=> route "" >=> Handlers.createTaskHandler
            DELETE >=> routef "/%O" Handlers.deleteTaskHandler
        ])

let webApp =
    choose [
        GET >=> route "/" >=> htmlView todoView
        subRoute "/api"
            (choose [
                apiTodoRoutes
                GET >=> route "" >=> json { Response = "ToDo List API" }
                GET >=> routef "/%s" Handlers.sayHelloNameHandler
            ])
        setStatusCode 404 >=> text "Not Found"
    ]

Our todo routes live under the api subroute. We need to put the apiTodoRoutes route handler before the existing ones because the sayHelloNameHandler will be selected instead of our GET /api/todos route and return:

{ "Response": "Hello, todos" }

Next we have to implement the new handlers.

Handlers

Create a module above the routes called Handlers and add the sayHelloNameHandler function to it.

module Handlers =
    let sayHelloNameHandler (name:string) =
        fun (next : HttpFunc) (ctx : HttpContext) ->
            task {
                let msg = sprintf "Hello, %s" name
                return! json { Response = msg } next ctx
            }

Let's add the GET routes to our module:

let viewTasksHandler =
    fun (next : HttpFunc) (ctx : HttpContext) ->
        task {
            let store = ctx.GetService<TodoStore>()
            let todos = store.GetAll()
            return! json todos next ctx
        }

let viewTaskHandler (id:Guid) =
    fun (next : HttpFunc) (ctx : HttpContext) ->
        task {
            let store = ctx.GetService<TodoStore>()
            let todo = store.Get(id)
            return! json todo next ctx
        }

We are using the context (ctx) to gain access to the TodoStore instance we set up earlier.

Let's add the remaining handler for create, update and delete:

let createTaskHandler =
    fun (next : HttpFunc) (ctx : HttpContext) ->
        task {
            let! newTodo = ctx.BindJsonAsync<NewTodo>()
            let store = ctx.GetService<TodoStore>()
            let created = store.Create({ Id = Guid.NewGuid(); Description = newTodo.Description; Created = DateTime.UtcNow; IsCompleted = false })
            return! json created next ctx
        }

let updateTaskHandler =
    fun (next : HttpFunc) (ctx : HttpContext) ->
        task {
            let! todo = ctx.BindJsonAsync<Todo>()
            let store = ctx.GetService<TodoStore>()
            let created = store.Update(todo)
            return! json created next ctx
        }

let deleteTaskHandler (id:Guid) =
    fun (next : HttpFunc) (ctx : HttpContext) ->
        task {
            let store = ctx.GetService<TodoStore>()
            let existing = store.Get(id)
            let deleted = store.Delete(KeyValuePair<TodoId, Todo>(id, existing))
            return! json deleted next ctx
        }

The most interesting thing here is that we use a built-in function to gain strongly-typed access to the body passed into the handler.

Using the API

Run the app and use a tool like Postman to work with the API.

To get a list of all Todos, we call GET /api/todo. This should return an empty json array.

We create a Todo by calling PUT /api/todo with a json body like this:

{
    "Description": "Finish blog post"
}

You will receive a reponse of true. If you now call the list again, you will receive a json response like this:

[
    {
        "key": "ff5a1d35-4573-463d-b9fa-6402202ab411",
        "value": {
            "id": "ff5a1d35-4573-463d-b9fa-6402202ab411",
            "description": "Finish blog post",
            "created": "2021-03-12T13:47:39.3564455Z",
            "isCompleted": false
        }
    }
]

I'll leave the other routes for you to investigate.

The code for this post is available here:

https://gist.github.com/ianrussellsoftwarepark/f1c0815efe309ee6dd4bebf397d75f8d

Summary

I hope that you found this post in the Introduction to Web Programming in F# with Giraffe series useful and interesting. We have only scratched the surface of what is possible with Giraffe for creating APIs.

In the next post we will start to investigate HTML views with the Giraffe View Engine.

If you have any comments on this series of posts or suggestions for new ones, send me a tweet (@ijrussell) and let me know.

Zurück
Zurück

Alternate Ways of Creating Single Case Discriminated Unions in F#

Weiter
Weiter

Introduction to Web Programming in F# with Giraffe – Part 3