This gist outlines an approach to generic deserialization in Kotlin, using Vavr (which provides the Either functionality) and Jackson (for deserialization).
What it gives you is:
- A single deserialization method for turning JSON into object graphs
- The option to use custom deserialization methods for individual fields - or all fields of a particular data type
- Little to no rework when the JSON model changes, beyond updating your data classes
In exchange all that is required is that the naming and structure of your model classes mirror your JSON naming and structure.
OK lets get started.
This is the deserialization function and in this instance it is expressed as an extension of the HTTP4K HttpMessage class:
fun <D : Any> HttpMessage.deserialize(clazz: KClass<D>): Either<Response, D> {
return Try.of { Jackson.asA(this.bodyString(), clazz) }
.map { deserializedResponse -> right<Response, D>(deserializedResponse) }
.getOrElseGet { exception ->
val rawMessage = exception.message
val message = if (rawMessage != null) {
when {
rawMessage.contains(" (through reference chain") -> {
rawMessage.substring(0, rawMessage.indexOf(" (through reference chain")).split("\n")[0]
}
exception.message!!.split(":")[0].length < exception.message!!.split("\n")[0].length -> {
exception.message!!.split(":")[0]
}
else -> {
exception.message!!.split("\n")[0]
}
}
} else "(no details provided)"
left(ErrorResponse(BAD_REQUEST, "INVALID_PAYLOAD", "Unable to deserialize into ${clazz.simpleName}: $message").asResponse())
}
}
We use Jackson for the heavy lifting here, so you've all the normal options (such as replacing default deserialization methods for specific data types). No class level annotations are required, and no per-class deserialization code is needed either. Refactor at will, this is just my real world example of deserialization.
What this code does is to attempt to deserialize the request body into an instance of type clazz. The result is an Either:
- If deserialization is successful,
Either.rightcontains a populated class instance. - If deserialization fails, this code puts a
ResponseintoEither.leftthat is returned to the caller.
There's some logic in the failure scenario to extract the underlying deserialization failure reason here as well.
Here's an example of a data class, NewActivity, and the single line of code we use to deserialize the request body into the data class.
request is an HttpMessage instance (our deserialize method shown above is an extension on the HttpMessage class):
data class NewActivity(val distance: Int,
val duration: String,
val activityDate: LocalDate)
val newActivity = request.deserialize(NewActivity::class)NewActivity is an Either as described above.
This is of course a relatively simple example, but the deserialization target can be as complex as you like.
If you find you need a custom deserializer for a specific field, all you have to do is to write a deserializer class:
class DurationDeserializer: JsonDeserializer<String>() {
@Throws(IOException::class)
override fun deserialize(jsonParser: JsonParser, context: DeserializationContext): String {
// deserialization code here
}
}You can see a full example of a deserialization function in one of my other gists here.
To use this deserializer just add an annotation to the field that it applies to:
data class NewActivity(val distance: Int,
@JsonDeserialize(using = DurationDeserializer::class) val duration: String,
val activityDate: LocalDate)Jackson will automatically use the deserializer specified at runtime, ignoring its own default deserializer (and any global deserializers you might have configured). If you want a deserializer to apply to every field with a specific data type then this can be done as part of your Jackson configuration instead.
And that is literally all there is to it.
Here's a complete example of the extension method in use, in one of the resource classes (HTTP endpoints) of my service:
fun addActivity(request: Request): Response {
return asAnAuthorisedUser(request) { user ->
request.deserialize(NewActivity::class)
.mapSuccess { activity ->
activityService.addActivity(user.userId, activity.distance, activity.duration, activity.activityDate)
.map { activityAdded ->
Response(OK)
.header("Content-Type", "application/json")
.body(Jackson.asJsonString(activityAdded))
}
.getOrElseGet { Response(INTERNAL_SERVER_ERROR) }
}
}
}This method:
- Checks that the user is authorised for the activity (it will stop and return a 403 if unauthorized).
- Deserializes the request body and then invokes a service method (it will stop and return a deserialization error if this fails).
- Invokes
activityService.addActivity- If everything is good it returns a 200 response with a JSON body.
- If there's an internal error it will return a 500 response.