Handling optional JSON fields for PATCH APIs with Jackson/Kotlin
When HTTP/1.0 was first proposed in 1996, it defined only 3 methods in RFC
1945: GET
,
HEAD
, and POST
.
Then RFC 2616
introduces many other methods including PUT
and DELETE
in 1999.
But it’s not until 2010 that
RFC 5789 finally introduces
the PATCH
method.
Perhaps that’s why it’s often forgotten and rarely discussed.
The PATCH
method
The existing
HTTP PUT method only allows a complete replacement of a document.
This proposal adds a new HTTP method, PATCH, to modify an existing
HTTP resource.
This method is really quite useful, and I would go so far as to say that the
majority of REST-y CRUD-y “update” operations should use PATCH
rather than
PUT
. It allows us to:
- save on bandwidth by only submitting fields that change
- minimise race conditions where the last updater wins (imagine a resource
with 1000 fields, but you only want to change 1 of them - usingPUT
you
essentially “lock” out and introduce race conditions on all 1000 fields)
There are multiple ways to implement a PATCH
API - indeed, RFC 5789
doesn’t really specify an implementation.
In the JSON world, there are two main ways:
If we have a resource that looks like this:
1 | { |
and we want to modify just the name to “Johnny Doe”, then a PUT
API might
look like:
1 | { |
a JSON Merge Patch would look like:
1 | { |
and a JSON Patch would look like:
1 | [{ |
We’re going to focus on JSON Merge Patch, since it’s the simplest, and
sufficient in most cases.
undefined
: the black sheep
People love to hate on undefined
vs null
in JavaScript. Honestly though,
I’ve never had an issue with it. Even though they may be often
interchangeable, they clearly represent two semantically different concepts.
And nowhere is it clearer than with a JSON Merge Patch request.
Everything is simpler when all the fields in your resource are non-nullable,
but when null
is a valid value for your field, you need to clearly
disambiguate between 3 states:
- I want to update the
"name"
field (use astring
) - I want to update the
"name"
field to null (usenull
) - I don’t want to update the
"name"
field (omit the field entirely, ie
undefined
semantics)
Implementing PATCH
APIs with Jackson / Kotlin
Unfortunately, it’s not exactly easy to properly disambiguate between
“missing field” and “null field” when deserializing with Jackson (or perhaps
with any popular library - it certainly seems difficult with kotlinx. serialization
too).
Level 1: Optional<T>?
There’s actually a workable solution out of the box: a nullable optional field.
1 | data class UserPatch( |
Note that java.util.Optional
does not allow containing null
values, so
Optional<String?>
isn’t an option.
Out of the box, this nullable optional field does kind of work:
Value | Null | Undefined | |
---|---|---|---|
Json | "name": "value" |
"name": null |
|
Kotlin | Optional.of(value) |
Optional.empty() |
null |
Serialized | "name": "value" |
"name": null |
"name": null ❌ |
When deserializing (the important part as a webserver), we are correctly
able to disambiguate between the three states. However:
- it is really confusing that
null
meansundefined
- when serializing, we still get
null
written out for theundefined
case, unless we ass an annotation like@JsonInclude(Include.NON_NULL)
on the field
Level 2: custom OptionalField<T?>
We can define our own class that does allow containing null
values, and
this gives it all the right semantics.
Here’s what my implementation looks like:
1 | /** |
And this is what it looks like to use:
Value | Null | Undefined | |
---|---|---|---|
Json | "name": "value" |
"name": null |
|
Kotlin | OptionalField.Present(value) |
null |
OptionalField.Undefined |
Serialized | "name": "value" |
"name": null |
Perfect.
Except, of course, it’s not that easy. Usually, writing custom serializers
and deserializers for Jackson is pretty painless. However, when you want to
change the behaviour of a serializer to omit a field entirely, you end up
having to dive pretty deep into Jackson internals.
With a normal custom serializer, Jackson doesn’t invoke your serializer
until it’s already written "name":
.
So we need to jump in earlier, using a different technique. I won’t bore
you with the 4+ hour journey of cross-referencing ChatGPT hallucinations
against documentation and ample testing, and just show you the final code.
Everything required for serialization:
1 | import com.fasterxml.jackson.core.JsonGenerator |
Everything required for deserialization:
1 | import com.fasterxml.jackson.databind.* |
Finally, how to hook it all up as a Jackson module:
1 | import com.fasterxml.jackson.databind.module.SimpleModule |
Level 3: Use jackson-databind-nullable
Yeah, after all that, I found that someone has done this already (of course).
But it was pretty hard to find:
jackson-databind-nullable
Some things I like more about my version:
- I wrote every line of it, so I know exactly what’s going on (I just
like this - a personal failing) - I think
OptionalField
makes a lot more sense thanJsonNullable
as a
class name - Mine has only the bare minimum. Take away anything and it stops working
But jackson-databind-nullable
:
- is used by a lot more people and is more battle-tested
- is more than bare minimum, which makes me worry that I’m missing something