yanefedor
yanefedor

Reputation: 2272

Kotlin and Jackson - Missing type id when trying to resolve subtype of simple type

I have a Kotlin sealed class - Pet and two subclasses - Dog and Cat. My application requires to transfer a collection of pets serialized in JSON. In order to differentiate subclasses I use Jackson @JsonTypeInfo and @JsonSubTypes annotations. The listing below:

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type")
@JsonSubTypes(
    JsonSubTypes.Type(value = Dog::class, name = "dog"),
    JsonSubTypes.Type(value = Cat::class, name = "cat")
)
sealed class Pet { abstract val name: String }

data class Dog(override val name: String): Pet()
data class Cat(override val name: String): Pet()

Single instances are serialized and deserialized properly:

    @Test
    fun `serialize dog`() {
        val dog = Dog("Kevin")
        val dogJson = objectMapper.writeValueAsString(dog)

        JsonAssert.assertEquals(dogJson, """{"type":"dog","name":"Kevin"}""")
        val newDog = objectMapper.readValue<Dog>(dogJson)
    }

The problem comes up when a collection of pets is being serialized and deserialized:

    @Test
    fun `serialize dog and cat`() {
        val pets: Set<Pet> = setOf(Dog("Kevin"), Cat("Marta"))
        val petsJson = objectMapper.writeValueAsString(pets)

        JsonAssert.assertEquals(petsJson, """[{"name":"Kevin"},{"name":"Marta"}]""")
        val newPets = objectMapper.readValue<Set<Pet>>(petsJson)
    }

Jackson swallows the type property during serialization and because of that objectMapper is not able to readValue:

com.fasterxml.jackson.databind.exc.InvalidTypeIdException: Missing type id when trying to resolve subtype of [simple type, class s3.moria.workflows.common.model.Pet]: missing type id property 'type'
 at [Source: (String)"[{"name":"Kevin"},{"name":"Marta"}]"; line: 1, column: 17] (through reference chain: java.util.HashSet[0])

Any ideas how tackle this problem? Or workarounds?

Jackson version: 2.9.0

Upvotes: 8

Views: 8833

Answers (2)

Vlad L
Vlad L

Reputation: 1694

This is actually not a bug, but a feature. For collections with generics, Jackson will ignore your subtypes annotations. There is a discussion here about it:

https://github.com/FasterXML/jackson-databind/issues/1816

The following 2 "fixes" work for me, and require less setup than the answer above (I think we are using different jackson versions perhaps, but I couldn't get jackson to work with a non-default constructor for a subclass, so I rewrote the subclass definition with lateinit)

One approach to overcome this is here:

Create your own set writer

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type")
@JsonSubTypes(
    JsonSubTypes.Type(value = Dog1::class, name = "dog"),
    JsonSubTypes.Type(value = Cat1::class, name = "cat")
)
sealed class Pet1 {
    abstract val name: String
}

class Dog1 : Pet1() {
    override lateinit var name: String
}

class Cat1 : Pet1() {
    override lateinit var name: String
}

These tests pass (again JSONAssert seems to be of a different method signature for me)

package com.example.demo

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import org.junit.jupiter.api.Test
import org.skyscreamer.jsonassert.JSONAssert

internal class PetTest1 {

    private var objectMapper = ObjectMapper()

    @Test
    fun `serialize dog`() {
        val dog = Dog1()
        dog.name = "Kevin"
        val dogJson = objectMapper.writeValueAsString(dog)

        JSONAssert.assertEquals(dogJson, """{"type":"dog","name":"Kevin"}""", true)
        val newDog = objectMapper.readValue<Dog1>(dogJson)
    }

    @Test
    fun `serialize dog and cat with mapper`() {
        val dog = Dog1()
        dog.name = "Kevin"
        val cat = Cat1()
        cat.name = "Marta"
        val pets: Set<Pet1> = setOf(dog, cat)
        val petCollectionType = objectMapper.typeFactory
            .constructCollectionType(Set::class.java, Pet1::class.java)

        val petsJson = objectMapper.writer().forType(petCollectionType).writeValueAsString(pets)

        JSONAssert.assertEquals(
            petsJson, """[{"type":"dog","name":"Kevin"},{"type":"cat","name":"Marta"}]""", true
        )
        val newPets = objectMapper.readValue<Set<Pet1>>(petsJson)
    }
}

You can also use this approach: Workaround without custom serializers/deserializers

Your code would look like:

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY)
@JsonSubTypes(
    JsonSubTypes.Type(value = Dog::class, name = "dog"),
    JsonSubTypes.Type(value = Cat::class, name = "cat")
)
sealed class Pet {
    abstract val jacksonMarker: String
        @JsonProperty("@type")
        get
    abstract val name: String
}

class Dog : Pet() {
    override val jacksonMarker: String
        get() = "dog"
    override lateinit var name: String
}

class Cat : Pet() {
    override val jacksonMarker: String
        get() =  "cat"
    override lateinit var name: String
}

The following tests pass

internal class PetTest {

    private var objectMapper = ObjectMapper()

    @Test
    fun `serialize dog`() {
        val dog = Dog()
        dog.name = "Kevin"
        val dogJson = objectMapper.writeValueAsString(dog)

        JSONAssert.assertEquals(dogJson, """{"@type":"dog","name":"Kevin"}""", true)
        val newDog = objectMapper.readValue<Dog>(dogJson)
    }

    @Test
    fun `serialize dog and cat`() {
        val dog = Dog()
        dog.name = "Kevin"
        val cat = Cat()
        cat.name = "Marta"
        val pets: Set<Pet> = setOf(dog, cat)
        val petsJson = objectMapper.writeValueAsString(pets)

        JSONAssert.assertEquals(
            petsJson, """[{"@type":"dog","name":"Kevin"},{"@type":"cat","name":"Marta"}]""", true)
        val newPets = objectMapper.readValue<Set<Pet>>(petsJson)
    }
}

Upvotes: 6

Naor Tedgi
Naor Tedgi

Reputation: 5707

changes you should make
first, at the JsonTypeInfo you need to set visibility to true then the type property be available in the deserializer.

then you need to implement the PetDeserializer

here is an example: Pet.kt

    import com.fasterxml.jackson.annotation.JsonSubTypes
    import com.fasterxml.jackson.annotation.JsonTypeInfo
    import com.fasterxml.jackson.core.JsonParser
    import com.fasterxml.jackson.core.JsonProcessingException
    import com.fasterxml.jackson.databind.DeserializationContext
    import com.fasterxml.jackson.databind.JsonNode
    import com.fasterxml.jackson.databind.annotation.JsonDeserialize
    import com.fasterxml.jackson.databind.deser.std.StdDeserializer
    import java.io.IOException
    import kotlin.jvm.Throws
    
    @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type",visible=true)
    @JsonSubTypes(
        JsonSubTypes.Type(value = Dog::class, name = "dog"),
        JsonSubTypes.Type(value = Cat::class, name = "cat")
    )
    
    
    @JsonDeserialize(using = PetDeserializer::class)
    sealed class Pet {
        abstract val name: String
    }
    
    data class Dog(override val name: String) : Pet()
    data class Cat(override val name: String) : Pet()
    
    class PetDeserializer @JvmOverloads constructor(vc: Class<*>? = Pet::class.java) :
        StdDeserializer<Pet?>(vc) {
        @Throws(IOException::class, JsonProcessingException::class)
        override fun deserialize(jp: JsonParser, ctxt: DeserializationContext): Pet {
            val node = jp.codec
                .readTree<JsonNode>(jp)
            val itemName = node["name"]
                .asText()
            val type = node["type"]
                .asText()
            return when (type) {
                "dog" -> Dog(itemName)
                "cat" -> Cat(itemName)
                else -> throw Error("unknown type")
            }
        }
    
        companion object {
            private const val serialVersionUID = 1883547683050039861L
        }
    }

and also PetTest.kt

import com.fasterxml.jackson.databind.ObjectMapper
import org.junit.Test
import org.skyscreamer.jsonassert.JSONAssert


class HelloTest {

    val objectMapper = ObjectMapper()

    @Test
    fun `serialize dog`() {
        val dog = Dog("Kevin")
        val dogJson = objectMapper.writeValueAsString(dog)
        JSONAssert.assertEquals("""{"type":"dog","name":"Kevin"}""", dogJson, false)
        val dogType = objectMapper.typeFactory.constructType(Dog::class.java)
        if (objectMapper.canDeserialize(dogType)) {
            ObjectMapper().readValue<Dog>(dogJson, Dog::class.java)
        } else {
              throw Error("deserializer not loaded")

        }
    }

}

Maven dependencies:

        <dependency>
            <groupId>com.fasterxml.jackson.datatype</groupId>
            <artifactId>jackson-datatype-jsr310</artifactId>
            <version>2.9.4</version>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.datatype</groupId>
            <artifactId>jackson-datatype-joda</artifactId>
            <version>2.9.4</version>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-core</artifactId>
            <version>2.9.4</version>
        </dependency>

        <dependency>
            <groupId>org.skyscreamer</groupId>
            <artifactId>jsonassert</artifactId>
            <version>1.5.0</version>
        </dependency>

Upvotes: 2

Related Questions