jackson-module-kotlin
jackson-module-kotlin copied to clipboard
Deserialization issue, @JsonCreator ignored
Search before asking
- [x] I searched in the issues and found nothing similar.
- [x] I have confirmed that the same problem is not reproduced if I exclude the KotlinModule.
- [x] I searched in the issues of databind and other modules used and found nothing similar.
- [ ] I have confirmed that the problem does not reproduce in Java and only occurs when using Kotlin and KotlinModule.
Describe the bug
Hello, While updating to spring boot 3.4.x I faced an issue related to the upgrade of jackson dependencies from 2.17.3 to 2.18.2 Deserialization is not working anymore for a class with 2 constructor, one with @JsonCreator annotation.
It fails with this exception:
Instantiation of [simple type, class User] value failed for JSON property fullName due to missing (therefore NULL) value for creator parameter fullName which is a non-nullable type
at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); line: 6, column: 13] (through reference chain: User["fullName"])
com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException: Instantiation of [simple type, class User] value failed for JSON property fullName due to missing (therefore NULL) value for creator parameter fullName which is a non-nullable type
at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); line: 6, column: 13] (through reference chain: User["fullName"])
at com.fasterxml.jackson.module.kotlin.KotlinValueInstantiator.createFromObjectWith(KotlinValueInstantiator.kt:97)
at com.fasterxml.jackson.databind.deser.impl.PropertyBasedCreator.build(PropertyBasedCreator.java:214)
at com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeUsingPropertyBased(BeanDeserializer.java:541)
at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.deserializeFromObjectUsingNonDefault(BeanDeserializerBase.java:1497)
at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:348)
at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:185)
at com.fasterxml.jackson.databind.deser.DefaultDeserializationContext.readRootValue(DefaultDeserializationContext.java:342)
at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4931)
at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3868)
at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3851)
at UserDeserializationTest.test user deserialization(UserDeserializationTest.kt:25)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
It seems it take the wrong constructor, I also try with @JsonIgnore and @JsonCreator(mode=DISABLED) on the first constructor but it didn't help.
Any idea on how to solve this issue?
To Reproduce
Project with failing test can be found on this repo https://github.com/fabienfleureau/jackson-deserialization-issue/
I have defined this class:
class User(
val age: Int,
fullName: String,
) {
var firstName: String = fullName.split(" ").first()
var lastName: String = fullName.split(" ").last()
fun fullName() = "$firstName $lastName"
@JsonCreator
constructor(): this(
age = 0,
fullName = "John Doe",
)
}
and the json to deserialize looks like this:
{
"age": 25,
"firstName": "Jane",
"lastName": "Doe"
}
Expected behavior
I expected to have a user deserialized having age set to 25, firstName set to Jane and lastName set to Doe
Versions
Kotlin: 2.1 Jackson-module-kotlin: 2.18.3 Jackson-databind: 2.18.3
Additional context
also created an issue in databind https://github.com/FasterXML/jackson-databind/issues/5040 but someone suggested to ask here
Which constructor are you expecting to call?
If I understand creator/constructor priority correctly, should be calling one with @JsonCreator thus age=0 and fullName=John Doe. Maby I misunderestand Kotlin-module behavior.
Full reproduction
class TestGithub22222 {
class User(
val age: Int,
fullName: String,
) {
var firstName: String = fullName.split(" ").first()
var lastName: String = fullName.split(" ").last()
fun fullName() = "$firstName $lastName"
@JsonCreator
constructor() : this(
age = 0,
fullName = "default value",
)
}
@Test
fun testUser() {
val objectMapper = jsonMapper {
addModule(kotlinModule())
}
val userJson = """
{
"age": 25,
"firstName": "My",
"lastName": "Name"
}
"""
val deserializedUser = objectMapper.readValue<User>(userJson)
assertEquals(25, deserializedUser.age)
assertEquals("My Name", deserializedUser.fullName())
}
}
Analysis
In 2.17 version : No Kotlin module functionality worked. Straight up jackson-databind module --check below screen shot of readValue stacktrace. Executed in follwoing order....
- empty constructor is called (annotated with
@JsonCreator) - then setter methods are called
In 2.18 version
- call path goes down to
KotlinValueInstantiator.createFromObjectWith() - Calls the
class User(val age: Int, fullName: String)for deserialization, can't find fullName as parameter, so fails. - Test would pass when we JSON input contains "fullName"
@cowtowncoder This may be another Kotlin module issue post-property-introspection-rewrite in databind.
Solution idea... short term
I wonder if instead we can...
- create another extension function for deciding which creator to choose like say we have
providePrimaryCreator()extend by Kotlin module... then - New version of
createFromObjectWith(DeserializationContext ctxt, Object[] args, Creator primaryCreator)is called.
Opinion on long-term
Regardless, we want to slim down KotlinValueInstantiator for Jackson 3 realease since Kotlin module is quite widely used.
I can't remember which issue but there was similar issue around KotlinValueInstantiator.createFromObjectWith() and property-rewrite in the past.🤔
@JooHyukKim
Personally, I felt that there may be an error in the databind regarding prioritization between DefaultCreator and explicit creator.
Since kotlin-module only reports DefaultCreator, the KotlinValueInstantiator._withArgsCreator should be set to the explicitly specified no-argument constructor.
On the other hand, debugging and checking actually sets the DefaultCreator.
I will see if it can be reproduced in Java.
I have submitted the issue to databind as I have detected a defect that can be reproduced at least in Java only.
https://github.com/FasterXML/jackson-databind/issues/5045
It may be superfluous, but I personally felt that it would be better to change the DTO.
It is a bit forced to write it, but the following is a possible approach.
data class User private constructor(
val age: Int,
val firstName: String,
val lastName: String,
) {
private constructor(age: Int, fullName: List<String>) : this(age, fullName[0], fullName[1])
constructor(age: Int, fullName: String) : this(age, fullName.split(" "))
}
Also, given the generation rules regarding fullName, it seems to me that it would be better to create a DTO like the following and use that.
data class FullName(
val firstName: String,
val lastName: String,
) {
companion object {
fun fromRawFullName(fullName: String) = fullName.split(" ").let {
if (it.length != 2) throw TODO()
FullName(it[0], it[1])
}
}
}
data class User private constructor(
val age: Int,
val firstName: String,
val lastName: String,
) {
constructor(age: Int, fullName: FullName) : this(age, fullName.firstName, fullName.lastName)
}
Hello, indeed the dto is not well structured but let's say I can't modify it due to "legacy" reasons. 🙃 Thanks for digging
Also, even by adding @JsonIgnore or @Creator(mode=DISABLED) to the default constructor it is not ignored.
FWTW, @JsonCreator(mode = DISABLED) definitely should make constructor ignored (if not a bug). @JsonIgnore interesting -- conceptually it should cause ignoral, too, but might not be checked (worth an issue for jackson-databind).
Why former is not ignored is probably due to changes in processing -- some Creators are kept in processing list for a bit before removed so maybe accidentally passed to method that selects the preferred Creator.
Quick note: databind side now has a fix for issue as reported (https://github.com/FasterXML/jackson-databind/issues/5045), for branch 2.x (2.21.0-SNAPSHOT).
That fix does lead to failures of Github722 (#722) tests FWTW.
Filed #1051 for regression; it's an odd one.
I am experiencing a similar issue: with 2.18.0 up to and including 2.19.2, the primary constructor (even if it is private) is selected over an explicit @JsonCreator unless I mark the primary constructor as @JsonCreator(mode = JsonCreator.Mode.DISABLED). For example, this crashes:
import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.module.kotlin.jsonMapper
import com.fasterxml.jackson.module.kotlin.kotlinModule
class JsonSafeImmutableMap private constructor(
private val delegate: Map<String, Any?>
) : Map<String, Any?> {
override val size: Int
get() = delegate.size
override fun isEmpty(): Boolean {
return delegate.isEmpty()
}
override fun containsKey(key: String): Boolean {
return delegate.containsKey(key)
}
override fun containsValue(value: Any?): Boolean {
return delegate.containsValue(value)
}
override fun get(key: String): Any? {
return delegate.get(key)
}
override val keys: Set<String>
get() = delegate.keys
override val values: Collection<Any?>
get() = delegate.values
override val entries: Set<Map.Entry<String, Any?>>
get() = delegate.entries
override fun equals(other: Any?): Boolean {
return delegate.equals(other)
}
override fun hashCode(): Int {
return delegate.hashCode()
}
override fun toString(): String {
return delegate.toString()
}
companion object {
@JsonCreator
@JvmStatic
fun from(jsonNode: JsonNode): JsonSafeImmutableMap? {
if (jsonNode.isObject) {
val map = LinkedHashMap<String, Any?>()
for ((key, value) in jsonNode.properties()) {
map[key] = value
}
return JsonSafeImmutableMap(map)
} else if (jsonNode.isNull) {
return null
} else {
throw IllegalArgumentException()
}
}
}
}
fun main(vararg args: String) {
val objectMapper = jsonMapper {
addModule(kotlinModule())
}
val actual = objectMapper.readValue("""{"a":1}""", JsonSafeImmutableMap::class.java)
println(actual)
}
@zhenlin-pay2 Does 2.20.0 make any difference?
2.20.0 has the same issue.