Skip to content

Added type match check to read functions #937

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Apr 12, 2025
3 changes: 3 additions & 0 deletions release-notes/CREDITS-2.x
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ Contributors:

# 2.19.0 (not yet released)

WrongWrong (@k163377)
* #937: Added type match check to read functions

Tatu Saloranta (@cowtowncoder)
* #889: Upgrade kotlin dep to 1.9.25 (from 1.9.24)

Expand Down
8 changes: 8 additions & 0 deletions release-notes/VERSION-2.x
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ Co-maintainers:
=== Releases ===
------------------------------------------------------------------------

2.19.0 (not yet released)

#937: For `readValue` and other shorthands for `ObjectMapper` deserialization methods,
type consistency checks have been added.
A `RuntimeJsonMappingException` will be thrown in case of inconsistency.
This fixes a problem that broke `Kotlin` null safety by reading null as a value even if the type parameter was specified as non-null.
It also checks for custom errors in ObjectMapper that cause a different value to be read than the specified type parameter.

2.19.0-rc2 (07-Apr-2025)

#929: Added consideration of `JsonProperty.isRequired` added in `2.19` in `hasRequiredMarker` processing.
Expand Down
126 changes: 119 additions & 7 deletions src/main/kotlin/com/fasterxml/jackson/module/kotlin/Extensions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.MappingIterator
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.ObjectReader
import com.fasterxml.jackson.databind.RuntimeJsonMappingException
import com.fasterxml.jackson.databind.json.JsonMapper
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.databind.node.ArrayNode
Expand Down Expand Up @@ -50,21 +51,132 @@ fun ObjectMapper.registerKotlinModule(initializer: KotlinModule.Builder.() -> Un

inline fun <reified T> jacksonTypeRef(): TypeReference<T> = object: TypeReference<T>() {}

/**
* It is public due to Kotlin restrictions, but should not be used externally.
*/
inline fun <reified T> Any?.checkTypeMismatch(): T {
// Basically, this check assumes that T is non-null and the value is null.
// Since this can be caused by both input or ObjectMapper implementation errors,
// a more abstract RuntimeJsonMappingException is thrown.
if (this !is T) {
val nullability = if (null is T) "?" else "(non-null)"

// Since the databind implementation of MappingIterator throws RuntimeJsonMappingException,
// JsonMappingException was not used to unify the behavior.
throw RuntimeJsonMappingException(
"Deserialized value did not match the specified type; " +
"specified ${T::class.qualifiedName}${nullability} but was ${this?.let { it::class.qualifiedName }}"
)
}
return this
}

/**
* Shorthand for [ObjectMapper.readValue].
* @throws RuntimeJsonMappingException Especially if [T] is non-null and the value read is null.
* Other cases where the read value is of a different type than [T]
* due to an incorrect customization to [ObjectMapper].
*/
inline fun <reified T> ObjectMapper.readValue(jp: JsonParser): T = readValue(jp, jacksonTypeRef<T>())
inline fun <reified T> ObjectMapper.readValues(jp: JsonParser): MappingIterator<T> = readValues(jp, jacksonTypeRef<T>())
.checkTypeMismatch()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Originally, there is an overhead of generating a TypeReference each time, and this change adds another overhead of checking.
Therefore, after checking benchmarks, I will add a document to avoid using it in situations where performance is important.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If performance is issue.... would simple try-catch instead of this type check(this PR) have better performance?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First, I confirmed that there was already an 8% drop in throughput before the change.
https://github.com/k163377/read-value-benchmark/blob/master/reports/results.csv

The throughput is high enough to begin with, so I don't think the performance of this function will be an issue in basic use cases.
However, I am considering giving only comments for use cases that require extreme performance.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After a little more consideration, I decided not to make a performance statement because it is unlikely that the overhead of this function will actually dominate.
371a858

/**
* Shorthand for [ObjectMapper.readValues].
* @throws RuntimeJsonMappingException Especially if [T] is non-null and the value read is null.
* Other cases where the read value is of a different type than [T]
* due to an incorrect customization to [ObjectMapper].
*/
inline fun <reified T> ObjectMapper.readValues(jp: JsonParser): MappingIterator<T> {
val values = readValues(jp, jacksonTypeRef<T>())

return object : MappingIterator<T>(values) {
override fun nextValue(): T = super.nextValue().checkTypeMismatch()
}
}

inline fun <reified T> ObjectMapper.readValue(src: File): T = readValue(src, jacksonTypeRef<T>())
inline fun <reified T> ObjectMapper.readValue(src: URL): T = readValue(src, jacksonTypeRef<T>())
/**
* Shorthand for [ObjectMapper.readValue].
* @throws RuntimeJsonMappingException Especially if [T] is non-null and the value read is null.
* Other cases where the read value is of a different type than [T]
* due to an incorrect customization to [ObjectMapper].
*/
inline fun <reified T> ObjectMapper.readValue(src: File): T = readValue(src, jacksonTypeRef<T>()).checkTypeMismatch()
/**
* Shorthand for [ObjectMapper.readValue].
* @throws RuntimeJsonMappingException Especially if [T] is non-null and the value read is null.
* Other cases where the read value is of a different type than [T]
* due to an incorrect customization to [ObjectMapper].
*/
inline fun <reified T> ObjectMapper.readValue(src: URL): T = readValue(src, jacksonTypeRef<T>()).checkTypeMismatch()
/**
* Shorthand for [ObjectMapper.readValue].
* @throws RuntimeJsonMappingException Especially if [T] is non-null and the value read is null.
* Other cases where the read value is of a different type than [T]
* due to an incorrect customization to [ObjectMapper].
*/
inline fun <reified T> ObjectMapper.readValue(content: String): T = readValue(content, jacksonTypeRef<T>())
inline fun <reified T> ObjectMapper.readValue(src: Reader): T = readValue(src, jacksonTypeRef<T>())
.checkTypeMismatch()
/**
* Shorthand for [ObjectMapper.readValue].
* @throws RuntimeJsonMappingException Especially if [T] is non-null and the value read is null.
* Other cases where the read value is of a different type than [T]
* due to an incorrect customization to [ObjectMapper].
*/
inline fun <reified T> ObjectMapper.readValue(src: Reader): T = readValue(src, jacksonTypeRef<T>()).checkTypeMismatch()
/**
* Shorthand for [ObjectMapper.readValue].
* @throws RuntimeJsonMappingException Especially if [T] is non-null and the value read is null.
* Other cases where the read value is of a different type than [T]
* due to an incorrect customization to [ObjectMapper].
*/
inline fun <reified T> ObjectMapper.readValue(src: InputStream): T = readValue(src, jacksonTypeRef<T>())
.checkTypeMismatch()
/**
* Shorthand for [ObjectMapper.readValue].
* @throws RuntimeJsonMappingException Especially if [T] is non-null and the value read is null.
* Other cases where the read value is of a different type than [T]
* due to an incorrect customization to [ObjectMapper].
*/
inline fun <reified T> ObjectMapper.readValue(src: ByteArray): T = readValue(src, jacksonTypeRef<T>())

.checkTypeMismatch()

/**
* Shorthand for [ObjectMapper.readValue].
* @throws RuntimeJsonMappingException Especially if [T] is non-null and the value read is null.
* Other cases where the read value is of a different type than [T]
* due to an incorrect customization to [ObjectMapper].
*/
inline fun <reified T> ObjectMapper.treeToValue(n: TreeNode): T = readValue(this.treeAsTokens(n), jacksonTypeRef<T>())
.checkTypeMismatch()
/**
* Shorthand for [ObjectMapper.convertValue].
* @throws RuntimeJsonMappingException Especially if [T] is non-null and the value read is null.
* Other cases where the read value is of a different type than [T]
* due to an incorrect customization to [ObjectMapper].
*/
inline fun <reified T> ObjectMapper.convertValue(from: Any?): T = convertValue(from, jacksonTypeRef<T>())

.checkTypeMismatch()

/**
* Shorthand for [ObjectMapper.readValue].
* @throws RuntimeJsonMappingException Especially if [T] is non-null and the value read is null.
* Other cases where the read value is of a different type than [T]
* due to an incorrect customization to [ObjectMapper].
*/
inline fun <reified T> ObjectReader.readValueTyped(jp: JsonParser): T = readValue(jp, jacksonTypeRef<T>())
inline fun <reified T> ObjectReader.readValuesTyped(jp: JsonParser): Iterator<T> = readValues(jp, jacksonTypeRef<T>())
.checkTypeMismatch()
/**
* Shorthand for [ObjectMapper.readValues].
* @throws RuntimeJsonMappingException Especially if [T] is non-null and the value read is null.
* Other cases where the read value is of a different type than [T]
* due to an incorrect customization to [ObjectMapper].
*/
inline fun <reified T> ObjectReader.readValuesTyped(jp: JsonParser): Iterator<T> {
val values = readValues(jp, jacksonTypeRef<T>())

return object : Iterator<T> by values {
override fun next(): T = values.next().checkTypeMismatch<T>()
}
}
inline fun <reified T> ObjectReader.treeToValue(n: TreeNode): T? = readValue(this.treeAsTokens(n), jacksonTypeRef<T>())

inline fun <reified T, reified U> ObjectMapper.addMixIn(): ObjectMapper = this.addMixIn(T::class.java, U::class.java)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package com.fasterxml.jackson.module.kotlin

import com.fasterxml.jackson.databind.RuntimeJsonMappingException
import com.fasterxml.jackson.databind.node.NullNode
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.io.StringReader

class ReadValueTest {
@Nested
inner class CheckTypeMismatchTest {
@Test
fun jsonParser() {
val src = defaultMapper.createParser("null")
assertThrows<RuntimeJsonMappingException> {
defaultMapper.readValue<String>(src)
}.printStackTrace()
}

@Test
fun file() {
val src = createTempJson("null")
assertThrows<RuntimeJsonMappingException> {
defaultMapper.readValue<String>(src)
}
}

// Not implemented because a way to test without mocks was not found
// @Test
// fun url() {
// }

@Test
fun string() {
val src = "null"
assertThrows<RuntimeJsonMappingException> {
defaultMapper.readValue<String>(src)
}
}

@Test
fun reader() {
val src = StringReader("null")
assertThrows<RuntimeJsonMappingException> {
defaultMapper.readValue<String>(src)
}
}

@Test
fun inputStream() {
val src = "null".byteInputStream()
assertThrows<RuntimeJsonMappingException> {
defaultMapper.readValue<String>(src)
}
}

@Test
fun byteArray() {
val src = "null".toByteArray()
assertThrows<RuntimeJsonMappingException> {
defaultMapper.readValue<String>(src)
}
}

@Test
fun treeToValueTreeNode() {
assertThrows<RuntimeJsonMappingException> {
defaultMapper.treeToValue<String>(NullNode.instance)
}
}

@Test
fun convertValueAny() {
assertThrows<RuntimeJsonMappingException> {
defaultMapper.convertValue<String>(null)
}
}

@Test
fun readValueTypedJsonParser() {
val reader = defaultMapper.reader()
val src = reader.createParser("null")
assertThrows<RuntimeJsonMappingException> {
reader.readValueTyped<String>(src)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.fasterxml.jackson.module.kotlin

import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.RuntimeJsonMappingException
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import com.fasterxml.jackson.databind.module.SimpleModule
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import kotlin.test.assertEquals

class ReadValuesTest {
class MyStrDeser : StdDeserializer<String>(String::class.java) {
override fun deserialize(
p: JsonParser,
ctxt: DeserializationContext
): String? = p.valueAsString.takeIf { it != "bar" }
}

@Nested
inner class CheckTypeMismatchTest {
val mapper = jacksonObjectMapper().registerModule(
object : SimpleModule() {
init {
addDeserializer(String::class.java, MyStrDeser())
}
}
)!!

@Test
fun readValuesJsonParserNext() {
val src = mapper.createParser(""""foo"${"\n"}"bar"""")
val itr = mapper.readValues<String>(src)

assertEquals("foo", itr.next())
assertThrows<RuntimeJsonMappingException> {
itr.next()
}
}

@Test
fun readValuesJsonParserNextValue() {
val src = mapper.createParser(""""foo"${"\n"}"bar"""")
val itr = mapper.readValues<String>(src)

assertEquals("foo", itr.nextValue())
assertThrows<RuntimeJsonMappingException> {
itr.nextValue()
}
}

@Test
fun readValuesTypedJsonParser() {
val reader = mapper.reader()
val src = reader.createParser(""""foo"${"\n"}"bar"""")
val itr = reader.readValuesTyped<String>(src)

assertEquals("foo", itr.next())
assertThrows<RuntimeJsonMappingException> {
itr.next()
}
}
}
}
17 changes: 17 additions & 0 deletions src/test/kotlin/com/fasterxml/jackson/module/kotlin/TestCommons.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import com.fasterxml.jackson.core.util.DefaultIndenter
import com.fasterxml.jackson.core.util.DefaultPrettyPrinter
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.ObjectWriter
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStreamWriter
import java.nio.charset.StandardCharsets
import kotlin.reflect.KParameter
import kotlin.reflect.full.memberProperties
import kotlin.reflect.full.primaryConstructor
Expand All @@ -30,3 +34,16 @@ internal inline fun <reified T : Any> assertReflectEquals(expected: T, actual: T
assertEquals(it.get(expected), it.get(actual))
}
}

internal fun createTempJson(json: String): File {
val file = File.createTempFile("temp", ".json")
file.deleteOnExit()
OutputStreamWriter(
FileOutputStream(file),
StandardCharsets.UTF_8
).use { writer ->
writer.write(json)
writer.flush()
}
return file
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.Test
import java.lang.reflect.InvocationTargetException
import kotlin.test.assertNotEquals

class WithoutCustomDeserializeMethodTest {
companion object {
Expand Down Expand Up @@ -42,10 +43,8 @@ class WithoutCustomDeserializeMethodTest {
// failing
@Test
fun nullString() {
org.junit.jupiter.api.assertThrows<NullPointerException>("#209 has been fixed.") {
val result = defaultMapper.readValue<NullableObject>("null")
assertEquals(NullableObject(null), result)
}
val result = defaultMapper.readValue<NullableObject?>("null")
assertNotEquals(NullableObject(null), result, "kogera #209 has been fixed.")
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.fasterxml.jackson.module.kotlin.kogeraIntegration.deser.valueClass.Pr
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import kotlin.test.assertNotEquals

class SpecifiedForObjectMapperTest {
companion object {
Expand Down Expand Up @@ -48,10 +49,8 @@ class SpecifiedForObjectMapperTest {
// failing
@Test
fun nullString() {
org.junit.jupiter.api.assertThrows<NullPointerException>("#209 has been fixed.") {
val result = mapper.readValue<NullableObject>("null")
assertEquals(NullableObject("null-value-deser"), result)
}
val result = mapper.readValue<NullableObject?>("null")
assertNotEquals(NullableObject("null-value-deser"), result, "kogera #209 has been fixed.")
}
}
}
Expand Down