2

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?

user3840170
  • 26,597
  • 4
  • 30
  • 62
Ogre
  • 781
  • 3
  • 10
  • 30
  • just a quick recap question: why do you define `Absent` and `Present` members as `OptionalProperty` within `OptionalProperty` class? I mean, for each member it may result a recursive declaration.. am I missing something here? Can you share why did you choose this specific implementation? – ymz May 08 '22 at 11:05
  • In Jackson, you can use Jdk8Module (jackson-datatype-jdk8). It provides the ability to deserialize java.util.Optional. explicit json null value = Optional, absent property = null. Example: https://www.baeldung.com/jackson-optional – Eugene May 08 '22 at 11:29
  • @ymz They are not members, but rather, the subclasses of the sealed class. I don't see how this is recursive. – Ogre May 09 '22 at 05:31

1 Answers1

0

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

devatherock
  • 2,423
  • 1
  • 8
  • 23
  • Is there no way to do this in a generic fashion? I don't want to deserialise every optional property as a String. Sometimes they are integers, sometimes they will be objects of a class. – Ogre May 09 '22 at 05:28
  • I didn't know how to make the implementation generic, but I was able to deserialize as `Any` instead of `String`. Modified the answer(and tests) to make both `id` and `name` optional. id gets deserialized as `Int` and name as `String` – devatherock May 09 '22 at 13:14