Building REST APIs in Giraffe: CRUD operations

2024-12-20

Hello again! In the previous part of this tutorial, we covered the basics of Giraffe routing and handlers; let’s start building our app!

A giraffe with orange sky in the background

As mentioned at the start of the series, we will build an API for managing a to-do list. Each to-do item should have the following attributes:

The API should have the usual CRUD (Create-Read-Update-Delete) endpoints:

Now that we know what we’re going to build, let’s roll up our sleeves and get started!

Data Modeling

First, let’s create a new type to model to-do items:

type Todo = {
    title: string
    completed: bool
    id: string
}

Second, we need to store the to-do items somewhere. Since this is just a tutorial we can settle for storing them in memory for now. Let’s create a Map that stores to-dos indexed by their ids:

let mutable todos: Map<string, Todo> = Map.empty

Don’t forget to declare it as mutable—otherwise we won’t be able to update it!

Creating to-dos

Since the list of to-dos is empty, let’s add a route for creating to-dos. Put this definition above webApp:

let createTodo  next (ctx: HttpContext) = task {
    // Empty for now
}

First, we need to read the user’s input; in this case we only expect the user to provide a “title” for the to-do, so the input will be a JSON body that looks something like:

{"title": "Return video tapes"}

Fortunately for us, Giraffe comes with a bunch of facilities for reading JSON; HttpContext.BindJsonAsync allows us to read a JSON body from the request and deserialize it to a given F# type. What’s more, BindJsonAsync also works with anonymous records. This means we don’t need to define a new type just to deserialize the input—we can just do something like this:

let! (input: {| title: string |}) = ctx.BindJsonAsync()

What’s going on here, exactly? By giving input the type {| title: string |} we tell BindJsonAsync to deserialize the input into a record with a title label, with the type string. We can now use the input to create and save a new to-do item:

let createTodo  next (ctx: HttpContext) = task {
    let! (input: {| title: string |}) =
        ctx.BindJsonAsync()
    // Create a to-do item:
    let todo = {
        title = input.title
        completed = false
        // Use System.Guid to create unique IDs:
        id = System.Guid.NewGuid().ToString()
    }
    // Add the new item to the list of todos:
    todos <- Map.add todo.id todo todos
    // Respond with the to-do we just created:
    return! ctx.WriteJsonAsync todo
}

Note that we use ctx.WriteJsonAsync to respond to the request with the to-do we just created. WriteJsonAsync works like the WriteTextAsync function we used in the previous example, except it serializes the given value to JSON.

Finally, we need to connect the createTodo handler with a route:

let webApp =
    choose [
        POST >=> route "/" >=> createTodo
    ]

Note that we use the POST handler to filter requests; this means that unless the request uses the “POST” method it won’t reach the route "/" handler.

Making requests

Let’s try out our new endpoint! Start the server with dotnet run and make sure it boots up properly. Now, let’s make a request—you can use any HTTP client you like, but we will use curl for this tutorial. If you prefer a graphical interface you can try Bruno.

Run the following curl command in a terminal:

curl -H "Content-Type: application/json" \
  --data '{"title": "Return video tapes"}' \
  http://localhost:5009/

If everything worked out, you should get a response like this:

{
    "title": "Return video tapes",
    "completed": false,
    "id": "2686cf1f-a1f2-4017-95fd-1a33c0b7b66b"
}

dotnet watch

If you want the server to restart automatically you can use dotnet watch run—this will automatically rebuild and restart the program when a source file is changed.

Reading to-dos

Now that we can create to-dos, let’s write a handler for reading them. This is actually quite simple—we can use the same WriteJsonAsync method as we used in the previous endpoint:

let listTodos next (ctx: HttpContext) =
    ctx.WriteJsonAsync todos.Values

Note that we output todos.Values, which gives us a List of all our to-dos.

Next, let’s hook up our listTodos handler to a route:

let webApp =
    choose [
        POST >=> route "/" >=> createTodo
        GET >=> route "/" >=> listTodos
    ]

Stop the server and rebuild it with dotnet run. Unfortunately this means we lose all the to-dos we created previously, as they were stored in memory (admittedly, this makes our application a bit less useful than it could be). Before you try out the new endpoint, it could be a good idea to create some to-dos by running the command from the previous step.

Now, let’s see what this endpoint can do:

curl http://localhost:5009/

The output should look something like this:

[
    {
        "title": "Pick up business cards from printer",
        "completed": false,
        "id": "04f5afd3-a2ab-4ab0-8c57-a0cbacf59e3b"
    },
    {
        "title": "Return video tapes",
        "completed": false,
        "id": "42c71b46-591a-4f1c-9f8a-d00414d4d882"
    }
]

Very nice!

Updating to-dos

We’ve covered creating and reading to-dos; now, let’s look at updating them.

To update a to-do item, the client should send a PATCH request to the /{id} URL, where {id} is the ID of the to-do. If there is no such to-do, we should return a 404 error.

Let’s start with the logic for fetching a to-do:

let updateTodo todoId next (ctx: HttpContext) = task {
    match Map.tryFind todoId todos with
    | Some todo ->
        return! ctx.WriteJsonAsync todo
    | None ->
        return! (RequestErrors.NOT_FOUND "To-do not found" next ctx)
}

We try to find the to-do using Map.tryFind; if we find a to-do we write it to the response, otherwise we return a 404 error using Giraffe’s built-in RequestErrors.NOT_FOUND handler. Finally, let’s add the new handler to our routes:

let webApp =
    choose [
        POST >=> route "/" >=> createTodo
        GET >=> route "/" >=> listTodos
        PATCH >=> routef "/%s" updateTodo
    ]

Note that we use routef to create a dynamic route, where %s should be the ID of the to-do. You can refer to the previous part of the series if you need a reminder on how routef works.

Now we know how to fetch a to-do; let’s write the logic for updating it. There is a certain subtlety to updating to-dos: We want to be able to support updating either updating the title field, the completed field, or both. If a field is omitted, it should keep its original value. To get the behaviour we want, let’s use BindJsonAsync with an anonymous record type, where both title and completed are optional:

let! (input: {| title: string option; completed: bool option |}) =
    ctx.BindJsonAsync()

We can now use the input to conditionally update the fields in the to-do item:

let updatedTodo = {
    todo with
        title = Option.defaultValue todo.title input.title
        completed = Option.defaultValue todo.completed input.completed
}

Putting it all together we get:

let updateTodo todoId next (ctx: HttpContext) = task {
    match Map.tryFind todoId todos with
    | Some todo ->
        let! (input: {| title: string option; completed: bool option |}) =
            ctx.BindJsonAsync()
        let updatedTodo = {
            todo with
                title = Option.defaultValue todo.title input.title
                completed = Option.defaultValue todo.completed input.completed
        }
        todos <- Map.add todoId updatedTodo todos
        // Note that we return the updated to-do item!
        return! ctx.WriteJsonAsync updatedTodo
    | None ->
        return! (RequestErrors.NOT_FOUND "To-do not found" next ctx)
}

Let’s try it out—restart the application and enter the following curl command:

curl -H "Content-Type: application/json" \
  -X PATCH \
  -d '{"title": "Return some DVDs"}' \
  http://localhost:5009/$ID
  # Replace $ID with the ID of one of your to-dos

You should get something like this back:

{
    "title": "Return some DVDs",
    "completed": false,
    "id": "90901063-7b9a-4367-907c-3ecdb44b5e1e"
}

Deleting to-dos

Time for the final piece of the puzzle—deleting to-dos. This handler is pretty simple; given the ID of a to-do, delete it if it exists or return a 404 response otherwise:

let deleteTodo todoId next (ctx: HttpContext) =
    match Map.tryFind todoId todos with
    | Some _ ->
        todos <- Map.remove todoId todos
        Successful.NO_CONTENT next ctx
    | None ->
        return! (RequestErrors.NOT_FOUND "To-do not found" next ctx)

We use the built-in Successful.NO_CONTENT handler to return a 204 No Content response, which is a common way to respond to successful “DELETE” requests.

As with the other handlers, we wrap it up by adding it to our list of routes:

let webApp =
    choose [
        POST >=> route "/" >=> createTodo
        GET >=> route "/" >=> listTodos
        PATCH >=> routef "/%s" updateTodo
        DELETE >=> routef "/%s" deleteTodo
    ]

You should now be able to delete to-dos by issuing a curl command like the one below:

# Replace $ID with the ID of one of your to-dos
curl -X DELETE http://localhost:5009/$ID

A slight refactor

Astute readers may have noticed that there is some duplication between the updateTodo and deleteTodo routes; both of them need to fetch a to-do item, and return a 404 response if the to-do item is not found. Can we refactor this somehow?

Since Giraffe handlers are just functions, we can split them up and compose them any way we want. Let’s restructure our app a bit, and break out the logic for fetching to-dos into a separate handler function:

let webApp =
    choose [
        POST >=> route "/" >=> createTodo
        GET >=> route "/" >=> listTodos
        routef "/%s" (fun todoId ->
            match Map.tryFind todoId todos with
            | Some todo ->
                choose [
                    PATCH >=> updateTodo todo
                    DELETE >=> deleteTodo todo
                ]
            | None -> RequestErrors.NOT_FOUND "Todo not found")
    ]

Note that we now pass in a Todo record to the updateTodo and deleteTodo handlers instead of a string; this means we can simplify those handlers to look like this:

let updateTodo todoId next (ctx: HttpContext) = task {
    let! (input: {| title: string option; completed: bool option |}) =
        ctx.BindJsonAsync()
    let updatedTodo = {
        todo with
            title = Option.defaultValue todo.title input.title
            completed = Option.defaultValue todo.completed input.completed
    }
    todos <- Map.add todoId updatedTodo todos
    return! ctx.WriteJsonAsync updatedTodo
}

let deleteTodo todoId next (ctx: HttpContext) =
    todos <- Map.remove todoId todos
    Successful.NO_CONTENT next ctx

Wrapping up

Great job! We’ve created a REST API that implements create, read, update and delete operations, and learned the basics of Giraffe handlers. Note that there is a lot of additions and improvements we could make (proper error handling, for example), but in the interest of keeping things simple I will leave that as an exercise for the reader.

Tune in next time to find out how we can save our to-do items to a database, so they don’t disappear as soon as we restart the application. Until next time!