How to encode a raw, unescaped JSON string? - json

I have a Kotlin server that acts as a gateway, handling communication between a client and a number of backing services over a REST APIs, using JSON. My server uses Kotlinx Serialization for serialization.
Usually I need to parse and adapt the responses from the backing services, but occasionally I just want to return the raw JSON content as a response.
For example:
import kotlinx.serialization.json.*
fun main() {
// I get some JSON from a backing service
val backingServiceResponse = """
{"some":"json",id:123,content:[]}
""".trimIndent()
// I create a response object, that I will return to the client
val packet = ExampleClientResponse("name", backingServiceResponse)
val encodedPacket = Json.encodeToString(packet)
println(encodedPacket)
// I expect that the JSON is encoded without quotes
require("""{"name":"name","content":{"some":"json",id:123,content:[]}}""" == encodedPacket)
}
#Serializable
data class ExampleClientResponse(
val name: String,
val content: String, // I want this to be encoded as unescaped JSON
)
However, the value of content is surrounded by quotes, and is escaped
{
"name":"name",
"content":"{\"some\":\"json\",id:123,content:[]}"
}
What I want is for the content property to be literally encoded:
{
"name":"name",
"content":{
"some":"json",
"id":123,
"content":[]
}
}
I am using Kotlin 1.8.0 and Kotlinx Serialization 1.4.1.

Encoding raw JSON is possible in Kotlinx Serialization 1.5.0-RC, which was released on 26th Jan 2023, and is experimental. It is not possible in earlier versions.
Create a custom serializer
First, create a custom serializer, RawJsonStringSerializer, that will encode/decode strings.
Encoding needs to use the new JsonUnquotedLiteral function to encode the content, if we're encoding the string as JSON
Since the value being decoded might be a JSON object, array, or primitive, it must use JsonDecoder, which has the decodeJsonElement() function. This will dynamically decode whatever JSON data is present to a JsonElement, which can be simply be converted to a JSON string using toString().
import kotlinx.serialization.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*
import kotlinx.serialization.json.*
private object RawJsonStringSerializer : KSerializer<String> {
override val descriptor = PrimitiveSerialDescriptor("my.project.RawJsonString", PrimitiveKind.STRING)
/**
* Encodes [value] using [JsonUnquotedLiteral], if [encoder] is a [JsonEncoder],
* or with [Encoder.encodeString] otherwise.
*/
#OptIn(ExperimentalSerializationApi::class)
override fun serialize(encoder: Encoder, value: String) = when (encoder) {
is JsonEncoder -> encoder.encodeJsonElement(JsonUnquotedLiteral(value))
else -> encoder.encodeString(value)
}
/**
* If [decoder] is a [JsonDecoder], decodes a [kotlinx.serialization.json.JsonElement] (which could be an object,
* array, or primitive) as a string.
*
* Otherwise, decode a string using [Decoder.decodeString].
*/
override fun deserialize(decoder: Decoder): String = when (decoder) {
is JsonDecoder -> decoder.decodeJsonElement().toString()
else -> decoder.decodeString()
}
}
Apply the custom serializer
Now in your class, you can annotated content with #Serializable(with = ...) to use the new serializer.
import kotlinx.serialization.*
#Serializable
data class ExampleClientResponse(
val name: String,
#Serializable(with = RawJsonStringSerializer::class)
val content: String,
)
Result
Nothing has changed in the main method - Kotlinx Serialization will automatically encode content literally, so it will now succeed.
import kotlinx.serialization.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*
import kotlinx.serialization.json.*
fun main() {
val backingServiceResponse = """
{"some":"json",id:123,content:[]}
""".trimIndent()
val packet = ExampleClientResponse("name", backingServiceResponse)
val encodedPacket = Json.encodeToString(packet)
println(encodedPacket)
require("""{"name":"name","content":{"some":"json",id:123,content:[]}}""" == encodedPacket)
}
Reducing duplication
Using #Serializable(with = ...) is simple when it's a on-off usage, but what if you have lots of properties that you want to encode as literal JSON?
Typealias serialization
When a fix is released in Kotlin 1.8.20 this will be possible with a one-liner
// awaiting fix https://github.com/Kotlin/kotlinx.serialization/issues/2083
typealias RawJsonString = #Serializable(with = RawJsonStringSerializer::class) String
#Serializable
data class ExampleClientResponse(
val name: String,
val content: RawJsonString, // will be encoded literally, without escaping
)
Raw encode a value class
Until Kotlinx Serialization can handle encoding typealias-primitives, you can use an inline value class, which we tell Kotlinx Serialization to encode using RawJsonStringSerializer.
#JvmInline
#Serializable(with = RawJsonStringSerializer::class)
value class RawJsonString(val content: String) : CharSequence by content
Now now annotation is needed in the data class:
#Serializable
data class ExampleClientResponse(
val name: String,
val content: RawJsonString, // will be encoded as a literal JSON string
)
RawJsonStringSerializer needs to be updated to wrap/unwrap the value class
#OptIn(ExperimentalSerializationApi::class)
private object RawJsonStringSerializer : KSerializer<RawJsonString> {
override val descriptor = PrimitiveSerialDescriptor("my.project.RawJsonString", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): RawJsonString = RawJsonString(decoder.decodeString())
override fun serialize(encoder: Encoder, value: RawJsonString) = when (encoder) {
is JsonEncoder -> encoder.encodeJsonElement(JsonUnquotedLiteral(value.content))
else -> encoder.encodeString(value.content)
}
}
The tradeoff is that it's a little clunky to convert to/from the new value class.
val backingServiceResponse = """
{"some":"json",id:123,content:[]}
""".trimIndent()
// need to wrap backingServiceResponse in the RawJsonString value class
val packet = ExampleClientResponse("name", RawJsonString(backingServiceResponse))
This answer was written using
Kotlin 1.8
Kotlinx Serialization 1.5.0-RC.

Related

How can I JSON encode BigDecimal and BigInteger in Kotlinx Serialization without losing precision?

I'm using Kotlin/JVM 1.8.0 and Kotlinx Serialization 1.4.1.
I need to encode a java.math.BigDecimal and java.math.BigInteger to JSON.
I'm using BigDecimal and BigInteger because the values I want to encode can be larger than a Double can hold, and also I want to avoid errors with floating-point precision. I don't want to encode the numbers as strings because JSON is read by other programs, so it needs to be correct.
The JSON spec places no restriction on the length of numbers, so it should be possible.
When I try and use BigDecimal and BigInteger directly, I get an error
import java.math.*
import kotlinx.serialization.*
import kotlinx.serialization.json.*
#Serializable
data class FooNumbers(
val decimal: BigDecimal,
val integer: BigInteger,
)
Serializer has not been found for type 'BigDecimal'. To use context serializer as fallback, explicitly annotate type or property with #Contextual
Serializer has not been found for type 'BigInteger'. To use context serializer as fallback, explicitly annotate type or property with #Contextual
I tried creating custom serializers for BigDecimal and BigInteger (and typealiases for convenience), but because these use toDouble() and toLong() they lose precision!
typealias BigDecimalJson = #Serializable(with = BigDecimalSerializer::class) BigDecimal
private object BigDecimalSerializer : KSerializer<BigDecimal> {
override val descriptor = PrimitiveSerialDescriptor("java.math.BigDecimal", PrimitiveKind.DOUBLE)
override fun deserialize(decoder: Decoder): BigDecimal =
decoder.decodeDouble().toBigDecimal()
override fun serialize(encoder: Encoder, value: BigDecimal) =
encoder.encodeDouble(value.toDouble())
}
typealias BigIntegerJson = #Serializable(with = BigIntegerSerializer::class) BigInteger
private object BigIntegerSerializer : KSerializer<BigInteger> {
override val descriptor = PrimitiveSerialDescriptor("java.math.BigInteger", PrimitiveKind.LONG)
override fun deserialize(decoder: Decoder): BigInteger =
decoder.decodeLong().toBigInteger()
override fun serialize(encoder: Encoder, value: BigInteger) =
encoder.encodeLong(value.toLong())
}
When I encode and decode an example instance, a different result is returned.
import java.math.*
import kotlinx.serialization.*
import kotlinx.serialization.json.Json
#Serializable
data class FooNumbers(
val decimal: BigDecimalJson,
val integer: BigIntegerJson,
)
fun main() {
val fooDecimal = BigDecimal("0.1234567890123456789012345678901234567890")
val fooInteger = BigInteger("9876543210987654321098765432109876543210")
val fooNumbers = FooNumbers(fooDecimal, fooInteger)
println("$fooNumbers")
val encodedNumbers = Json.encodeToString(fooNumbers)
println(encodedNumbers)
val decodedFooNumbers = Json.decodeFromString<FooNumbers>(encodedNumbers)
println("$decodedFooNumbers")
require(decodedFooNumbers == fooNumbers)
}
The require(...) fails:
FooNumbers(decimal=0.1234567890123456789012345678901234567890, integer=9876543210987654321098765432109876543210)
{"decimal":0.12345678901234568,"integer":1086983617567424234}
FooNumbers(decimal=0.12345678901234568, integer=1086983617567424234)
Exception in thread "main" java.lang.IllegalArgumentException: Failed requirement.
at MainKt.main(asd.kt:32)
at MainKt.main(asd.kt)
Encoding raw JSON is possible in Kotlinx Serialization 1.5.0-RC, which was released on 26th Jan 2023, and is experimental. It is not possible in earlier versions.
tl:dr: skip to 'Full example' at the bottom of this answer
Decoding using JsonDecoder
Note that it's only encoding that requires the workaround - decoding BigDecimal and BigInteger will work directly, so long as JsonDecoder is used!
private object BigDecimalSerializer : KSerializer<BigDecimal> {
// ...
override fun deserialize(decoder: Decoder): BigDecimal =
when (decoder) {
// must use decodeJsonElement() to get the value, and then convert it to a BigDecimal
is JsonDecoder -> decoder.decodeJsonElement().jsonPrimitive.content.toBigDecimal()
else -> decoder.decodeString().toBigDecimal()
}
}
Encoding using JsonUnquotedLiteral
To encode, the new JsonUnquotedLiteral() function must be used when encoding JSON.
private object BigDecimalSerializer : KSerializer<BigDecimal> {
// ...
override fun serialize(encoder: Encoder, value: BigDecimal) =
when (encoder) {
// use JsonUnquotedLiteral() to encode the BigDecimal literally
is JsonEncoder -> encoder.encodeJsonElement(JsonUnquotedLiteral(value.toPlainString()))
else -> encoder.encodeString(value.toPlainString())
}
}
Global config using typealias
Kotlinx Serialization uses typealias to define globally available serialization strategies. Let's do the same for BigDecimal
typealias BigDecimalJson = #Serializable(with = BigDecimalSerializer::class) BigDecimal
Example usage
After creating the serializers, the typealiases can be used in FooNumber to automatically use the KSerializers.
#Serializable
data class FooNumbers(
val decimal: BigDecimalJson,
val integer: BigIntegerJson,
)
The actual main function doesn't change - it's the same as before.
fun main() {
val fooDecimal = BigDecimal("0.1234567890123456789012345678901234567890")
val fooInteger = BigInteger("9876543210987654321098765432109876543210")
val fooNumbers = FooNumbers(fooDecimal, fooInteger)
println("$fooNumbers")
val encodedNumbers = Json.encodeToString(fooNumbers)
println(encodedNumbers)
val decodedFooNumbers = Json.decodeFromString<FooNumbers>(encodedNumbers)
println("$decodedFooNumbers")
require(decodedFooNumbers == fooNumbers)
}
Now the BigDecimal and BigInteger can be encoded and decoded exactly, no loss of precision!
FooNumbers(decimal=0.1234567890123456789012345678901234567890, integer=9876543210987654321098765432109876543210)
{"decimal":0.1234567890123456789012345678901234567890,"integer":9876543210987654321098765432109876543210}
FooNumbers(decimal=0.1234567890123456789012345678901234567890, integer=9876543210987654321098765432109876543210)
Full example
Here's the full code:
import java.math.*
import kotlinx.serialization.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*
import kotlinx.serialization.json.*
#Serializable
data class FooNumbers(
val decimal: BigDecimalJson,
val integer: BigIntegerJson,
)
fun main() {
val fooDecimal = BigDecimal("0.1234567890123456789012345678901234567890")
val fooInteger = BigInteger("9876543210987654321098765432109876543210")
val fooNumbers = FooNumbers(fooDecimal, fooInteger)
println("$fooNumbers")
val encodedNumbers = Json.encodeToString(fooNumbers)
println(encodedNumbers)
val decodedFooNumbers = Json.decodeFromString<FooNumbers>(encodedNumbers)
println("$decodedFooNumbers")
require(decodedFooNumbers == fooNumbers)
}
typealias BigDecimalJson = #Serializable(with = BigDecimalSerializer::class) BigDecimal
#OptIn(ExperimentalSerializationApi::class)
private object BigDecimalSerializer : KSerializer<BigDecimal> {
override val descriptor = PrimitiveSerialDescriptor("java.math.BigDecimal", PrimitiveKind.DOUBLE)
/**
* If decoding JSON uses [JsonDecoder.decodeJsonElement] to get the raw content,
* otherwise decodes using [Decoder.decodeString].
*/
override fun deserialize(decoder: Decoder): BigDecimal =
when (decoder) {
is JsonDecoder -> decoder.decodeJsonElement().jsonPrimitive.content.toBigDecimal()
else -> decoder.decodeString().toBigDecimal()
}
/**
* If encoding JSON uses [JsonUnquotedLiteral] to encode the exact [BigDecimal] value.
*
* Otherwise, [value] is encoded using encodes using [Encoder.encodeString].
*/
override fun serialize(encoder: Encoder, value: BigDecimal) =
when (encoder) {
is JsonEncoder -> encoder.encodeJsonElement(JsonUnquotedLiteral(value.toPlainString()))
else -> encoder.encodeString(value.toPlainString())
}
}
typealias BigIntegerJson = #Serializable(with = BigIntegerSerializer::class) BigInteger
#OptIn(ExperimentalSerializationApi::class)
private object BigIntegerSerializer : KSerializer<BigInteger> {
override val descriptor = PrimitiveSerialDescriptor("java.math.BigInteger", PrimitiveKind.LONG)
/**
* If decoding JSON uses [JsonDecoder.decodeJsonElement] to get the raw content,
* otherwise decodes using [Decoder.decodeString].
*/
override fun deserialize(decoder: Decoder): BigInteger =
when (decoder) {
is JsonDecoder -> decoder.decodeJsonElement().jsonPrimitive.content.toBigInteger()
else -> decoder.decodeString().toBigInteger()
}
/**
* If encoding JSON uses [JsonUnquotedLiteral] to encode the exact [BigInteger] value.
*
* Otherwise, [value] is encoded using encodes using [Encoder.encodeString].
*/
override fun serialize(encoder: Encoder, value: BigInteger) =
when (encoder) {
is JsonEncoder -> encoder.encodeJsonElement(JsonUnquotedLiteral(value.toString()))
else -> encoder.encodeString(value.toString())
}
}

Deserialize empty string as null with kotlinx.serialization

I'm writing a client for a third-party REST API that returns JSON with a variety of alternative values instead of proper null or omitting the property entirely if null. Depending on the entity or even property in question, null could be represented by either null, "", "0" or 0.
It's easy enough to make a custom serializer, e.g. something like this works fine:
#Serializable
data class Task(
val id: String,
#Serializable(with = EmptyStringAsNullSerializer::class)
val parentID: String?
)
object EmptyStringAsNullSerializer : KSerializer<String?> {
private val delegate = String.serializer().nullable
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("EmptyStringAsNull", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: String?) {
when (value) {
null -> encoder.encodeString("")
else -> encoder.encodeString(value)
}
}
override fun deserialize(decoder: Decoder): String {
return delegate.deserialize(decoder) ?: ""
}
}
fun main() {
val json = """
{
"id": "37883993",
"parentID": ""
}
""".trimIndent()
val task = Json.decodeFromString(json)
println(task)
}
But annotating many properties like this is a bit ugly/noisy. And I'd also like to use inline/value classes for strong typing, like this:
#Serializable
data class Task(
val id: ID,
val parentID: ID?
/* .... */
) {
#JvmInline
#Serializable
value class ID(val value: String)
}
This means that in addition to annotating these properties I also need a custom serializer for each of them. I tried some generic/parameters-based solution that can work for all cases like this:
open class BoxedNullAsAlternativeValue<T, V>(
private val delegate: KSerializer<T>,
private val boxedNullValue: T,
private val unboxer: (T) -> V
) : KSerializer<T> {
private val unboxedNullValue by lazy { unboxer.invoke(boxedNullValue) }
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor(this::class.simpleName!!, PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: T) {
when (value) {
null -> delegate.serialize(encoder, boxedNullValue)
else -> delegate.serialize(encoder, value)
}
}
override fun deserialize(decoder: Decoder): T {
#Suppress("UNCHECKED_CAST")
return when (val boxedValue = delegate.deserialize(decoder)) {
boxedNullValue -> null as T
else -> boxedValue
}
}
}
But that doesn't work because #Serializable(with = ...) expects a static class reference as argument, so it can't have parameters or generics. Which means I'd still need a concrete object for each inline/value type:
#Serializable
data class Task(
val id: ID, // <-- missing serializer because custom serializer is of type ID? for parentID
val parentID: ID?
) {
#JvmInline
#Serializable(with = IDSerializer::class)
value class ID(val value: String)
}
internal object IDSerializer : BoxedNullAsAlternativeValue<Task.ID?, String>(
delegate = Task.ID.serializer().nullable, // <--- circular reference
boxedNullValue = Task.ID(""),
unboxer = { it.value }
)
That doesn't work because there is no longer a generic delegate like StringSerializer and using Task.ID.serializer() would mean the delegate would be the custom serializer itself, so a circular reference. It also fails to compile because one usage of the ID value class is nullable and the other not, so I would need nullable + non-nullable variants of the custom serializer and I would need to annotate each property individually again, which is noisy.
I tried writing a JsonTransformingSerializer but those need to be passed at the use site where encoding/decoding happens, which means I'd need to write one for the entire Task class, e.g. Json.decodeFromString(TaskJsonTransformingSerializer, json) and then also for all other entities of the api.
I found this feature request for handling empty strings as null, but it doesn't appear to be implemented and I need it for other values like 0 and "0" too.
Question
Using kotlinx.serialization and if necessary ktor 2, how to deserialize values like "", "0" and 0 as null for inline/values classes, considering that:
Properties of the same (value) type can be nullable and non-nullable in the same class, but I'd like to avoid having to annotate each property individually
I'd like a solution that is as generic as possible, i.e. not needing a concrete serializer for each value class
It needs to work both ways, i.e. deserializing and serializing
I read in the documentation that serializing is done in 2 distinct phases: breaking down a complex object to it's constituent primitives (serializing) --> writing the primitives as JSON or any other format (encoding). Or in reverse: decoding -> deserializing;
Ideally I'd let the compiler generate serializers for each value class, but annotate each of them with a reference to one of three value transformers (one each for "", "0" and 0) that sit in between the two phases, inspects the primitive value and replaces it when necessary.
I've been at this for quite some time, so any suggestions would be much appreciated.

kotlinx serialization — best way to do polymorphic child deserialization

I have a Json input like:
{
"type": "type_1",
"data": {
// ...
}
}
data field can vary depending on type.
So, I need a deserializer, that looks on type (enum) and deserializes data respectively (for instance, for type_1 value it's Type1 class, for type_2 — Type2, etc).
I thought about a fully-custom deserializer (extending a KSerializer<T>), but it looks like an overkill.
What's the best (kotlin) way to do such deserialization?
Kotlin way for polymorphic deserialization is to have a plain JSON (with all data fields on the same level as type field):
{
"type": "type_1",
// ...
}
and register all subclasses of abstract superclass with serializers module (this step could be skipped if superclass is a sealed class).
No need for enums - just mark subclasses declarations with respectful #SerialName("type_1") annotations if its name in JSON differs from fully-qualified class name.
If original JSON shape is a strict requirement, then you may transform it on the fly to a plain one, reducing the task to the previous one.
#Serializable(with = CommonAbstractSuperClassDeserializer::class)
abstract class CommonAbstractSuperClass
#Serializable
#SerialName("type_1")
data class Type1(val x: Int, val y: Int) : CommonAbstractSuperClass()
#Serializable
#SerialName("type_2")
data class Type2(val a: String, val b: Type1) : CommonAbstractSuperClass()
object CommonAbstractSuperClassDeserializer :
JsonTransformingSerializer<CommonAbstractSuperClass>(PolymorphicSerializer(CommonAbstractSuperClass::class)) {
override fun transformDeserialize(element: JsonElement): JsonElement {
val type = element.jsonObject["type"]!!
val data = element.jsonObject["data"] ?: return element
return JsonObject(data.jsonObject.toMutableMap().also { it["type"] = type })
}
}
fun main() {
val kotlinx = Json {
serializersModule = SerializersModule {
polymorphic(CommonAbstractSuperClass::class) {
subclass(Type1::class)
subclass(Type2::class)
}
}
}
val str1 = "{\"type\":\"type_1\",\"data\":{\"x\":1,\"y\":1}}"
val obj1 = kotlinx.decodeFromString<CommonAbstractSuperClass>(str1)
println(obj1) //Type1(x=1, y=1)
val str2 = "{\"type\":\"type_2\",\"data\":{\"a\":\"1\",\"b\":{\"x\":1,\"y\":1}}}"
val obj2 = kotlinx.decodeFromString<CommonAbstractSuperClass>(str2)
println(obj2) //Type2(a=1, b=Type1(x=1, y=1))
//Works for plain JSON shape as well:
val str0 = "{\"type\":\"type_1\",\"x\":1,\"y\":1}"
val obj0 = kotlinx.decodeFromString<CommonAbstractSuperClass>(str0)
println(obj0) //Type1(x=1, y=1)
}

Polymorphic deserialization with kotlinx.serialization in Kotlin/Native

I want to decode a json string containing a list of objects in a polymorphic class structure using kotlinx.serialization in a Kotlin Multiplatform project, but it works only on JVM, not on Native. Here is a minimum reproducible example:
#Serializable
abstract class Project {
abstract val name: String
}
#Serializable
#SerialName("BasicProject")
data class BasicProject(override val name: String): Project()
#Serializable
#SerialName("OwnedProject")
data class OwnedProject(override val name: String, val owner: String) : Project()
fun main() {
val data = Json.decodeFromString<List<Project>>("""
[
{"type":"BasicProject","name":"example"},
{"type":"OwnedProject","name":"kotlinx.serialization","owner":"kotlin"}
]
"""))
}
This works on JVM but throws the following exception on Native:
kotlinx.serialization.SerializationException: Serializer for class ‘Project’ is not found.
Mark the class as #Serializable or provide the serializer explicitly.
On Kotlin/Native explicitly declared serializer should be used for interfaces and enums without #Serializable annotation.message
This problem has been discussed before in the context of encoding and some workarounds have been suggested, e.g. here, but my problem is decoding. Is there a workaround, or do I simply have to implement my own json parser?
You need to explicitly pass respectful serializer and serializersModule:
object ListOfProjectSerializer : KSerializer<List<Project>> by ListSerializer(Project.serializer())
val module = SerializersModule {
polymorphic(Project::class) {
subclass(BasicProject::class)
subclass(OwnedProject::class)
}
}
fun main() {
val data = Json { serializersModule = module }.decodeFromString(
ListOfProjectSerializer,
"""
[
{"type":"BasicProject","name":"example"},
{"type":"OwnedProject","name":"kotlinx.serialization","owner":"kotlin"}
]
"""
)
}

Parsing empty object with kotlinx.serialization

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
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)
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.
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())
}
}
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"