'How to deserialise a field and tell the difference between the value being null or absent?

When I am deserialising into my class, for some fields, I would like to be able to tell the difference between the value being absent, or null. For example, {"id": 5, "name": null} should be considered different to {"id": 5}.

I have come across solutions for kotlinx.serialisation and Rust's serde, but so far, I'm struggling to achieve this in Jackson.

I'll use this class as an example:

    data class ResponseJson(
        val id: Int,
        @JsonDeserialize(using = OptionalPropertyDeserializer::class)
        val name: OptionalProperty<String?>
    )

The definition of the OptionalProperty field:

sealed class OptionalProperty<out T> {
    object Absent : OptionalProperty<Nothing>()
    data class Present<T>(val value: T?) : OptionalProperty<T>()
}

I've written a custom deserialiser:

class OptionalPropertyDeserializer :
    StdDeserializer<OptionalProperty<*>>(OptionalProperty::class.java),
    ContextualDeserializer
{
    override fun deserialize(p: JsonParser, ctxt: DeserializationContext): OptionalProperty<*> {
        println(p.readValueAs(ctxt.contextualType.rawClass))
        return OptionalProperty.Present(p.readValueAs(ctxt.contextualType.rawClass))
    }

    override fun getNullValue(ctxt: DeserializationContext?) = OptionalProperty.Present(null)
    override fun getAbsentValue(ctxt: DeserializationContext?) = OptionalProperty.Absent

    override fun createContextual(ctxt: DeserializationContext, property: BeanProperty): JsonDeserializer<*> {
        println(property.type.containedType(0))
        return ctxt.findContextualValueDeserializer(property.type.containedType(0), property)
    }
}

Finally, my ObjectMapper setup:

val messageMapper: ObjectMapper = jacksonObjectMapper()
    .disable(ADJUST_DATES_TO_CONTEXT_TIME_ZONE)
    .disable(ACCEPT_FLOAT_AS_INT)
    .enable(FAIL_ON_NULL_FOR_PRIMITIVES)
    .enable(FAIL_ON_NUMBERS_FOR_ENUMS)
    .setSerializationInclusion(NON_EMPTY)
    .disable(WRITE_DATES_AS_TIMESTAMPS)

Now, I try to deserialise some JSON:

    @Test
    fun deserialiseOptionalProperty() {
        assertEquals(
            ResponseJson(5, OptionalProperty.Present("fred")),
            messageMapper.readValue(
                //language=JSON
                """
                  {
                    "id": 5,
                    "name": "fred"
                  }
                """.trimIndent()
            )
        )
    }

I am getting the following exception:

com.fasterxml.jackson.databind.exc.ValueInstantiationException: Cannot construct instance of `serialisation_experiments.JacksonTests$ResponseJson`, problem: argument type mismatch
 at [Source: (String)"{
  "id": 5,
  "name": "fred"
}"; line: 4, column: 1]

What does "argument type mismatch" mean here? I assume I've done something incorrectly with the custom deserialiser, but what is the correct approach?



Solution 1:[1]

I used an implementation of OptionalProperty class that has a field to hold the value and a flag to indicate if the value was set. The flag will be set to true whenever the value is changed, using a custom setter. With such a class and the default jacksonObjectMapper(), I was able to get the deserialization of all scenarios working - name specified, name null and name missing. Below are the classes I ended up with:

OptionalProperty:

class OptionalProperty {
    var value: Any? = null
        set(value) {
            field = value
            valueSet = true
        }
    var valueSet: Boolean = false
}

ResponseJson:

data class ResponseJson (
    @JsonDeserialize(using = OptionalPropertyDeserializer::class)
    val id: OptionalProperty,
    @JsonDeserialize(using = OptionalPropertyDeserializer::class)
    val name: OptionalProperty
)

OptionalPropertyDeserializer:

import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer

class OptionalPropertyDeserializer : JsonDeserializer<OptionalProperty>() {
    override fun deserialize(parser: JsonParser, context: DeserializationContext): OptionalProperty {
        var property = OptionalProperty()
        property.value = parser.readValueAs(Any::class.java)
        return property
    }

    override fun getNullValue(context: DeserializationContext): OptionalProperty {
        var property = OptionalProperty()
        property.value = null
        return property
    }

    override fun getAbsentValue(context: DeserializationContext): OptionalProperty {
        return OptionalProperty()
    }
}

Tests:

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.Test

class ResponseJsonTest {
    var objectMapper = jacksonObjectMapper()

    @Test
    fun `test deserialization - id and name are non-null`() {
        var responseJson = objectMapper.readValue(
            """
                  {
                    "id": 5,
                    "name": "test"
                  }
                """.trimIndent(),
            ResponseJson::class.java
        )
        assert(responseJson.id.value == 5)
        assert(responseJson.id.valueSet)
        assert(responseJson.id.value is Int)
        assert(responseJson.name.value == "test")
        assert(responseJson.name.valueSet)
        assert(responseJson.name.value is String)
    }

    @Test
    fun `test deserialization - name is null`() {
        var responseJson = objectMapper.readValue(
            """
                  {
                    "id": 5,
                    "name": null
                  }
                """.trimIndent(),
            ResponseJson::class.java
        )
        assert(responseJson.id.value == 5)
        assert(responseJson.id.valueSet)
        assert(responseJson.id.value is Int)
        assert(responseJson.name.value == null)
        assert(responseJson.name.valueSet)
    }

    @Test
    fun `test deserialization - name is absent`() {
        var responseJson = objectMapper.readValue(
            """
                  {
                    "id": 5
                  }
                """.trimIndent(),
            ResponseJson::class.java
        )
        assert(responseJson.id.value == 5)
        assert(responseJson.id.valueSet)
        assert(responseJson.id.value is Int)
        assert(responseJson.name.value == null)
        assert(!responseJson.name.valueSet)
    }

    @Test
    fun `test deserialization - id is null`() {
        var responseJson = objectMapper.readValue(
            """
                  {
                    "id": null,
                    "name": "test"
                  }
                """.trimIndent(),
            ResponseJson::class.java
        )
        assert(responseJson.id.value == null)
        assert(responseJson.id.valueSet)
        assert(responseJson.name.value == "test")
        assert(responseJson.name.valueSet)
        assert(responseJson.name.value is String)
    }

    @Test
    fun `test deserialization - id is absent`() {
        var responseJson = objectMapper.readValue(
            """
                  {
                    "name": "test"
                  }
                """.trimIndent(),
            ResponseJson::class.java
        )
        assert(responseJson.id.value == null)
        assert(!responseJson.id.valueSet)
        assert(responseJson.name.value == "test")
        assert(responseJson.name.valueSet)
        assert(responseJson.name.value is String)
    }
}

The full project with main and test classes can be found in github

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1