Ingesting data in a Giraffe app might seem trivial, given its very convenient ctx.BindJsonASync<T>()
function. But there are a few common pitfalls here. In this blog post, we'll cover how to make sure you deserialize safely!
Throughout this post, we'll work with this simple handler, expanding on it in ways that will expose the pitfalls one by one.
type Greeting = { Addressee: string }
let message greeting =
sprintf "Hello %s, from Giraffe!" greeting.Addressee
let indexHandler next (ctx: HttpContext) =
task {
let! greeting = ctx.BindJsonAsync<Greeting>()
let message = message greeting
return! text message next ctx
}
So far, we're just using records with strings, and the serializer happily deals with correct requests:
{
"addressee": "Barry"
}
returns
Hello Barry, from Giraffe!
Pitfall 1: Complex types and decoding directly into your rich domain
As soon as we start expanding our model a bit, things go south. Let's see what happens if we add a Discriminated Union to our Greeting
:
type Tone =
| Formal
| Casual
type Greeting = {
Addressee: string
Tone: Tone
}
let message greeting =
match greeting.Tone with
| Formal -> sprintf "Salutations, %s. With the highest respect, Giraffe" greeting.Addressee
| Casual -> sprintf "Hello %s, from Giraffe!" greeting.Addressee
let greetingHandler next (ctx: HttpContext) = task {
let! greeting = ctx.BindJsonAsync<Greeting>()
let message = message greeting
return! text message next ctx
}
Simply passing the tone as string does not work:
{
"addressee": "Barry",
"tone": "casual"
}
returns a 500 response:
No 'Case' property with union name found. Path '', line 1, position 37.
Ugly requests
The JSON deserializer dictates the format { case: string; fields: string list }
. It makes for a pretty ugly request body:
{
"addressee": "Barry",
"tone": {
"case": "casual"
}
}
returns
Hello Barry, from Giraffe!
Tight coupling
What's even worse about directly serializing into your domain is that your API becomes super tightly coupled with your domain. Add an extra field to a record? The consumer needs to know. Change the name of a union case? Consumer needs to know! For a more complete coverage of more of the pain of deserializing JSON and how it relates to the domain, check out this blog post.
Instead, split out the data you ingest into very simple DTOs that can be made up from simple records. You can transform them into domain types easily. We use FsToolkit.ErrorHandling's result Computation Expression to validate records.
module Domain =
type Tone =
| Formal
| Casual
type Greeting = { Addressee: string; Tone: Tone }
let message greeting =
match greeting.Tone with
| Formal -> sprintf "Salutations, %s. With the highest respect, Giraffe" greeting.Addressee
| Casual -> sprintf "Hello %s, from Giraffe!" greeting.Addressee
module Dto =
type Greeting = { Addressee: string; Tone: string }
let toneFromString =
function
| "Formal" -> Ok Domain.Tone.Formal
| "Casual" -> Ok Domain.Tone.Casual
| unknown -> Error $"Unknown tone {unknown}"
let greetingFromDto dto =
result {
let! tone = toneFromString dto.Tone
return
{ Domain.Addressee = dto.Addressee
Domain.Tone = tone }
}
open Domain
let greetingHandler next (ctx: HttpContext) =
task {
let! greeting = ctx.BindJsonAsync<Dto.Greeting>()
match Dto.greetingFromDto greeting with
| Ok greeting ->
let message = message greeting
return! text message next ctx
| Error e -> return! (setStatusCode 400 >=> text e) next ctx
}
We can now make the request again, in a very simple format:
{
"addressee": "Barry",
"tone": "Casual"
}
Hello Barry, from Giraffe!
And if we use a wrong value for tone, we get a nice error message:
{
"addressee": "Barry",
"tone": "Mean"
}
Unknown tone Mean
We gained a bit of complexity on the server, but the consumer gained:
- Simple requests
- Nicer error messages, with an appropriate status code
- Less frequent API changes, because the API model is separate from the domain
Pitfall 2: decoding into null!
Everything seems OK, but what if the consumer forgets a field?
{
"tone": "Casual"
}
returns
Hello , from Giraffe!
Something is clearly missing; so far, we have not used any functions that fail on null, but don't be fooled; Adressee
was not decoded into an empty string; it was decoded into null!
This is a sneaky one: as you see, the null values spit out by the deserializer can stay hidden pretty well, until at some point they pop up as a runtime error! Let's improve our validation by checking if the addressee is not null, or just whitespace. In a real application, we'd make a domain type to store the validated value, but we'll omit that in this example.
let validateAddressee addressee =
if String.IsNullOrWhiteSpace addressee then
Error "Missing addressee"
else
Ok addressee
let greetingFromDto dto =
result {
let! tone = toneFromString dto.Tone
let! addressee = validateAddressee dto.Addressee
return
{ Domain.Addressee = addressee
Domain.Tone = tone }
}
That's a lot better!
{
"tone": "Casual"
}
returns a 400 response:
Missing addressee
Pitfall 3: Giving errors one by one
We're validating our Tone
and Addressee
, but what if both are missing?
{}
returns a 400 response:
Unknown tone
No lies there, but definitely not the whole truth either; It's pretty annoying to deal with one error, for another one to pop up:
{
"tone": "Casual"
}
returns another 400 response:
Missing addressee
Ideally, you'd see all errors at the same time. Fortunately, FsToolkit.ErrorHandling has the great validation
Computation Expression, that, when used with the and!
keyword, instead of immediately returning errors when they are encountered, adds them to a list:
module Dto =
...
let greetingFromDto dto =
validation {
let! tone = toneFromString dto.Tone
and! addressee = validateAddressee dto.Addressee
return
{ Domain.Addressee = addressee
Domain.Tone = tone }
}
...
let greetingHandler next (ctx: HttpContext) =
task {
let! greeting = ctx.BindJsonAsync<Dto.Greeting>()
match Dto.greetingFromDto greeting with
| Ok greeting ->
...
| Error e ->
let errorMessage = String.concat "; " e
return! (setStatusCode 400 >=> text errorMessage) next ctx
}
If we make the empty request again, we get a list of errors:
Unknown tone ; Missing addressee
Conclusion
As you can see, Giraffe's ctx.BindJsonAsync<Greeting>()
provides a great starting point for decoding JSON, but to create a usable API it does require a few good practices when it comes to parsing data. It's not very suitable for decoding straight into the domain, although you probably don't want to do that anyway, if you want your API to be maintainable. Always be wary of the possibility of null values sneaking into your app through the serializer, and make sure you parallelize your validation so your consumers don't waste time dealing with validation errors one by one!
If you're dealing with more complex JSON, we highly recommend checking out Thoth.Json
. It deals with most issues we addressed here out of the box. It has a bit of a learning curve, but once you get the hang of it, it provides a great way to write composable JSON decoders, with parallel validation included out of the box! We'll dedicate a blog post to it in the near future.