Description
Hello,
We encountered a weird behaviour with jackson-core 2.15.0 that was not present in version 2.14.2 :
Disclaimer : this is a corner case (but still annoying ;))
When trying to unmarshall the raw json {"list":[{"type":"impl","unmappedKey":"unusedValue"}]}
with "type" being the discriminator for a sealed class, I get this error
com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke "com.fasterxml.jackson.core.JsonParser.streamReadConstraints()" because "p" is null (through reference chain: com.orange.ccmd.connector.MyResponse["list"]->java.util.ArrayList[0])
In the debugger, there is no trace of a variable p
being null though.
This happens only when there is all three conditions:
- an extra field in the json that is not mapped to a field of the target class
- an extra member in the class that is not present in the json.
- a class hierarchy
Here is a complete working unit test showing the bug, with the stacktrace :
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.JsonMappingException
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
internal class JacksonBugTest {
private val objectMapper = ObjectMapper().apply {
disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
registerModules(KotlinModule.Builder().build())
}
@Test
fun thisFails() {
val rawResponse = """{"list":[{"type":"impl","unmappedKey":"unusedValue"}]}"""
shouldThrow<JsonMappingException> {
objectMapper.readValue(rawResponse, MyResponse::class.java)
}
/**
* com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke "com.fasterxml.jackson.core.JsonParser.streamReadConstraints()" because "p" is null (through reference chain: com.orange.ccmd.connector.MyResponse["list"]->java.util.ArrayList[0])
* at app//com.fasterxml.jackson.databind.JsonMappingException.wrapWithPath(JsonMappingException.java:402)
* at app//com.fasterxml.jackson.databind.JsonMappingException.wrapWithPath(JsonMappingException.java:373)
* at app//com.fasterxml.jackson.databind.deser.std.CollectionDeserializer._deserializeFromArray(CollectionDeserializer.java:375)
* at app//com.fasterxml.jackson.databind.deser.std.CollectionDeserializer.deserialize(CollectionDeserializer.java:244)
* at app//com.fasterxml.jackson.databind.deser.std.CollectionDeserializer.deserialize(CollectionDeserializer.java:28)
* at app//com.fasterxml.jackson.databind.deser.SettableBeanProperty.deserialize(SettableBeanProperty.java:545)
* at app//com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeWithErrorWrapping(BeanDeserializer.java:568)
* at app//com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeUsingPropertyBased(BeanDeserializer.java:439)
* at app//com.fasterxml.jackson.databind.deser.BeanDeserializerBase.deserializeFromObjectUsingNonDefault(BeanDeserializerBase.java:1409)
* at app//com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:352)
* at app//com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:185)
* at app//com.fasterxml.jackson.databind.deser.DefaultDeserializationContext.readRootValue(DefaultDeserializationContext.java:323)
* at app//com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4825)
* at app//com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3772)
* at app//com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3740)
* at app//com.orange.ccmd.connector.utils.CustomJacksonObjectMapperFactory.readObject(CustomJacksonJsonProvider.kt:36)
*/
}
@Test
fun `is ok if no missing field in json`() {
val rawResponse = """{"list":[{"type":"impl","missingInJson":"not missing","unmappedKey":"unusedValue"}]}"""
val marshalled = objectMapper.readValue(rawResponse, MyResponse::class.java)
marshalled shouldBe MyResponse(
list = listOf(Base.Impl(
type = "impl",
missingInJson = "not missing"
))
)
}
@Test
fun `is ok if no extra field in json`() {
val rawResponse = """{"list":[{"type":"impl"}]}"""
val marshalled = objectMapper.readValue(rawResponse, MyResponse::class.java)
marshalled shouldBe MyResponse(
list = listOf(Base.Impl(
type = "impl",
missingInJson = null
))
)
}
@Test
fun `is ok if no class hierarchy`() {
val rawResponse = """{"list":[{"type":"impl"}]}"""
val marshalled = objectMapper.readValue(rawResponse, MyResponseWithoutHierarchy::class.java)
marshalled shouldBe MyResponseWithoutHierarchy(
list = listOf(Base.Impl(
type = "impl",
missingInJson = null
))
)
}
}
data class MyResponse(
val list: List<Base>
)
data class MyResponseWithoutHierarchy(
val list: List<Base.Impl>
)
sealed class Base {
companion object {
@JsonCreator
@JvmStatic
fun unmarshall(
@JsonProperty("missingInJson") missingInJson: String? = null,
@JsonProperty("type") type: String? = null,
): Base? {
return when (type) {
"impl" -> Impl(
missingInJson = missingInJson,
type = type,
)
else -> null
}
}
}
data class Impl(
val type: String? = null,
val missingInJson: String? = null
) : Base()
}
Thanks for the invaluable work