Reputation: 2272
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
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:
@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
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