Registering Jackson sub-types at runtime in Kotlin

Jakub Zalas - Jun 2 '20 - - Dev Community

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")
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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} ")
    }
}
Enter fullscreen mode Exit fullscreen mode

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)))
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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))
    }
}
Enter fullscreen mode Exit fullscreen mode

It will be registered with the Jackson instance used by Spring.

. . . . . . . . . . . . .