Hello again! In the previous part of this tutorial, we covered the basics of Giraffe routing and handlers; let’s start building our app!
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:
title: string
The title of the to-docompleted: bool
Whether the to-do is completed or notid: string
A unique identifier
The API should have the usual CRUD (Create-Read-Update-Delete) endpoints:
GET /
Return a list of all to-dosPOST /
Create a new to-doPATCH /{id}
Update a to-doDELETE /{id}
Delete a to-do
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!