Jackson supports serialisation of polymorphic types out of the box.
Compile time sub-type registration
With @JsonTypeInfo
we can configure the discriminator property which will be used to determine the type of deserialised object. @JsonSubTypes
helps to define the set of available sub-types:
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "kind", visible = true)
@JsonSubTypes(
JsonSubTypes.Type(Rectangle::class, name = "rectangle"),
JsonSubTypes.Type(Circle::class, name = "circle")
)
abstract class Shape(val kind: String)
data class Rectangle(@JsonProperty val width: Int, val height: Int) : Shape("rectangle")
data class Circle(@JsonProperty val radius: Int) : Shape("circle")
Given some shapes serialised to JSON, we can now deserialise them back to concrete shape instances (like Rectangle
or Circle
):
@Test
fun `it reads sub types`() {
val rectangleJson = """{"width":10,"height":20,"kind":"rectangle"}"""
val circleJson = """{"radius":15,"kind":"circle"}"""
val objectMapper = jacksonObjectMapper()
val rectangle: Shape = objectMapper.readValue(rectangleJson)
val circle: Shape = objectMapper.readValue(circleJson)
assertEquals(Rectangle(10, 20), rectangle)
assertEquals(Circle(15), circle)
}
Jackson will determine the actual type based on the kind
property we specified in the @JsonTypeInfo
annotation.
Runtime sub-type registration
That's all good as long as the set of sub-types is known.
However, in some scenarios we won't be able to specify the complete list of sub-types at compile time. It's sometimes useful to provide sub-types at runtime, i.e. when they're coming from other modules or libraries.
Other times we wouldn't like the module that provides the base type to know about specific implementations (following the dependency inversion principle).
To continue the shapes example, imagine we'd like to enable users of the shapes library to add custom shapes in their projects.
With a bit of reflection and a Jackson module we can implement sub-type discovery at runtime.
To scan packages and find sub-types we'll use the classgraph library.
Here's a working example of a Jackson module that will scan the given package prefix looking for children of given parent classes:
package com.kaffeinelabs.jackson.subtype
import com.fasterxml.jackson.annotation.JsonTypeName
import com.fasterxml.jackson.core.Version
import com.fasterxml.jackson.databind.Module
import com.fasterxml.jackson.databind.jsontype.NamedType
import org.slf4j.LoggerFactory
import io.github.classgraph.ClassGraph
import io.github.classgraph.ClassInfo
import io.github.classgraph.ScanResult
import kotlin.reflect.KClass
import kotlin.reflect.full.findAnnotation
/**
* Finds all children of types given in the constructor and registers them as Jackson sub-types.
*
* Sub-types need to have the `@JsonTypeName` annotation.
* It will register types that would normally not be registered by Jackson.
* Specifically, subtypes can be put in any namespace and don't need to be sub classes of a sealed class.
*/
class SubTypeModule(private val prefix: String, private val parentTypes: List<KClass<*>>) : Module() {
companion object {
private val logger = LoggerFactory.getLogger(SubTypeModule::class.java)
}
override fun getModuleName(): String = "SubType"
override fun version(): Version = Version(1, 0, 0, "", "com.kaffeinelabs.jackson.subtype", "subtype")
override fun setupModule(context: SetupContext) {
context.registerSubtypes(*findJsonSubTypes().toTypedArray())
}
private fun findJsonSubTypes(): List<NamedType> {
val classes: ScanResult = scanClasses()
val subTypes = parentTypes.flatMap { classes.filterJsonSubTypes(it) }
logTypes(subTypes)
return subTypes
}
private fun scanClasses(): ScanResult = ClassGraph().enableClassInfo().whitelistPackages(prefix).scan()
private fun ScanResult.filterJsonSubTypes(type: KClass<*>): Iterable<NamedType> =
getSubclasses(type.java.name)
.map(ClassInfo::loadClass)
.map {
NamedType(it, it.findJsonTypeAnnotation())
}
private fun Class<*>.findJsonTypeAnnotation(): String = kotlin.findAnnotation<JsonTypeName>()?.value ?: "unknown"
private fun logTypes(subTypes: List<NamedType>) = subTypes.forEach {
logger.info("Registering json subtype ${it.name}: ${it.type.kotlin.qualifiedName} ")
}
}
To use it, we need to register the module on the Jackson object mapper:
val objectMapper = jacksonObjectMapper()
.registerModule(SubTypeModule("com.kaffeinelabs.shapes", listOf(Shape::class)))
The above configuration will look for Shape
implementations in the com.kaffeinelabs.shapes
package.
The Shape
type itself won't need @JsonSubTypes
anymore:
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "kind", visible = true)
abstract class Shape(val type: String)
Specific Shape
implementations can be put anywhere in the configured package (i.e. com.kaffeinelabs.shapes
), but they need to be named with @JsonTypeName
:
@JsonTypeName("rectangle")
data class Rectangle(@JsonProperty val width: Int, val height: Int) : Shape("rectangle")
@JsonTypeName("circle")
data class Circle(@JsonProperty val radius: Int) : Shape("circle")
With all this we're now able to serialize and deserialise shapes, using the sub-type definitions registered at runtime:
@Test
fun `it reads sub types`() {
val objectMapper = jacksonObjectMapper()
.registerModule(SubTypeModule("com.kaffeinelabs.shapes", listOf(Shape::class)))
val rectangleJson = """{"width":10,"height":20,"kind":"rectangle"}"""
val circleJson = """{"radius":15,"kind":"circle"}"""
val rectangle: Shape = objectMapper.readValue(rectangleJson)
val circle: Shape = objectMapper.readValue(circleJson)
assertEquals(Rectangle(10, 20), rectangle)
assertEquals(Circle(15), circle)
}
@Test
fun `it writes sub types`() {
val objectMapper = jacksonObjectMapper()
.registerModule(SubTypeModule("com.kaffeinelabs.shapes", listOf(Shape::class)))
val rectangleJson = objectMapper.writeValueAsString(Rectangle(10, 20))
val circleJson = objectMapper.writeValueAsString(Circle(15))
assertEquals("""{"width":10,"height":20,"kind":"rectangle"}""", rectangleJson)
assertEquals("""{"radius":15,"kind":"circle"}""", circleJson)
}
Spring Boot
To register the submodule in a Spring Boot application, define a bean for our module:
@Configuration
class JacksonConfig {
@Bean
fun subTypeModule(): Module {
return SubTypeModule("com.kaffeinelabs.shapes", listOf(Shape::class))
}
}
It will be registered with the Jackson instance used by Spring.