0

I am struggling to understand how to parse an empty object {} with the experimental kotlinx.serialization library. The complication arises when in fact an API response can be one of;

{
  "id": "ABC1",
  "status": "A_STATUS"
}

or

{}

The data structure I have used as my serializer is;

data class Thing(val id: String = "", val status: String = "")

This is annotated with @kotlinx.serialization.Serializable and used within an API client library to marshall between the raw API response and the data model. The default values tell the serialisation library that the field is optional and replaces the @Optional approach of pre-Kotlin 1.3.30.

Finally, the kotlinx.serialization.json.Json parser I am using has the configuration applied by using the nonstrict template.

How do I define a serializer that can parse both an empty object and the expected data type with kotlinx.serialization? Do I need to write my own KSerialiser or is there config I am missing. Ideally, the empty object should be ignored/parsed as a null?

The error I get when parsing an empty object with my Thing data class is;

Field 'id' is required, but it was missing
BrantApps
  • 6,362
  • 2
  • 27
  • 60
  • `id` shouldn't be required in case, as you state, you are using [a recent version and have supplied a default value](https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/basic-serialization.md#optional-properties) (don't recall when exactly this was introduced). So I suspect you _aren't_ using the correct version. – Steven Jeuris Dec 08 '21 at 13:53
  • Note that this of course requires that the JSON encoder is configured so that [defaults are not encoded](https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/basic-serialization.md#defaults-are-not-encoded), which is the default. – Steven Jeuris Dec 08 '21 at 14:00

2 Answers2

1

So this was down to the kotlinCompilerClasspath having a different version kotlin (1.3.21, not 1.3.31).

Interestingly this was owing to advice I followed when configuring my gradle plugin project to not specify a version for the kotlin-dsl plugin.

Explicitly relying on the version I needed fixed the kotlinx.serialisation behavior (no changes to the mainline code)

BrantApps
  • 6,362
  • 2
  • 27
  • 60
-1

Yes, ideally null instead of {} is way more convenient to parse but sometimes you just need to consume what backend sends you

There are 2 solutions that come to my mind.

  1. Simpler, specific to your case using map:
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test

class ThingMapSerializerTest {

    @Test
    fun `should deserialize to non empty map`() {
        val thingMap: Map<String, String> =
            Json.decodeFromString("""{"id":"ABC1","status":"A_STATUS"}""")

        assertTrue(thingMap.isNotEmpty())
        assertEquals("ABC1", thingMap["id"])
        assertEquals("A_STATUS", thingMap["status"])
    }

    @Test
    fun `should deserialize to empty map`() {
        val thingMap: Map<String, String> = Json.decodeFromString("{}")

        assertTrue(thingMap.isEmpty())
    }
}
  1. More complex but more general that works for any combinations of value types. I recommend sealed class with explicit empty value instead of data class with empty defaults:
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.descriptors.serialDescriptor
import kotlinx.serialization.encoding.CompositeDecoder
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.encoding.decodeStructure
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Test

class ThingSerializerTest {

    @Test
    fun `should deserialize to thing`() {
        val thing: OptionalThing =
            Json.decodeFromString(
                OptionalThing.ThingSerializer,
                """{"id":"ABC1","status":"A_STATUS"}"""
            )

        assertEquals(OptionalThing.Thing(id = "ABC1", status = "A_STATUS"), thing)
    }

    @Test
    fun `should deserialize to empty`() {
        val thing: OptionalThing =
            Json.decodeFromString(OptionalThing.ThingSerializer, "{}")

        assertEquals(OptionalThing.Empty, thing)
    }

    sealed class OptionalThing {
        data class Thing(val id: String = "", val status: String = "") : OptionalThing()
        object Empty : OptionalThing()

        object ThingSerializer : KSerializer<OptionalThing> {
            override val descriptor: SerialDescriptor =
                buildClassSerialDescriptor("your.app.package.OptionalThing") {
                    element("id", serialDescriptor<String>(), isOptional = true)
                    element("status", serialDescriptor<String>(), isOptional = true)
                }

            override fun deserialize(decoder: Decoder): OptionalThing {
                decoder.decodeStructure(descriptor) {
                    var id: String? = null
                    var status: String? = null
                    loop@ while (true) {
                        when (val index = decodeElementIndex(descriptor)) {
                            CompositeDecoder.DECODE_DONE -> break@loop
                            0 -> id = decodeStringElement(descriptor, index = 0)
                            1 -> status = decodeStringElement(descriptor, index = 1)
                            else -> throw SerializationException("Unexpected index $index")
                        }
                    }
                    return if (id != null && status != null) Thing(id, status)
                    else Empty
                }
            }

            override fun serialize(encoder: Encoder, value: OptionalThing) {
                TODO("Not implemented, not needed")
            }
        }
    }

}

When 'Thing' is a field within json object:

  "thing":{"id":"ABC1","status":"A_STATUS"} // could be {} 

you can annotate property like that:

@Serializable(with = OptionalThing.ThingSerializer::class)
val thing: OptionalThing

Tested for:

  • classpath "org.jetbrains.kotlin:kotlin-serialization:1.4.10"
  • implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1"
fada21
  • 3,188
  • 1
  • 22
  • 21
  • None of this should be needed. As the OP expects, when `Thing("", "")` is serialized, it should be serialized as `{}` [when defaults are not encoded](https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/basic-serialization.md#defaults-are-not-encoded), which is the default setting for the JSON encoder. – Steven Jeuris Dec 08 '21 at 13:58