Search code examples
kotlinserialization

JsonDecodingException with custom serializer


I am trying to write custom serializer, because I need to deserialize object into string. Deserialization of object is done correctly, but when I try to deserialize simple String I got exception:

kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 10: Expected quotation mark '"', but had 't' instead at path: $.plan
JSON input: {"plan": "test"}

Object to serialize

@Serializable
data class Test(
    val plan: PlanAsString?
)

Serializer

typealias PlanAsString = @Serializable(with = PlanSerializer::class) String

class PlanSerializer : KSerializer<String?> {
    override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("StringSerializer", PrimitiveKind.STRING)

    @OptIn(ExperimentalSerializationApi::class)
    override fun deserialize(decoder: Decoder): String? {
        kotlin.runCatching {
            val plan = decoder.decodeSerializableValue(Test.serializer())
            return plan.plan
        }.onFailure {
            return decoder.decodeString()
        }
        return decoder.decodeNull()
    }

    @OptIn(ExperimentalSerializationApi::class)
    override fun serialize(encoder: Encoder, value: String?) {
        return value?.let {
            encoder.encodeString(value)
        } ?: encoder.encodeNull()
    }
}

And code where I am trying to deserializer string into Object

Json.decodeFromString<Test>("{\"plan\": \"test\"}")  // This rows throws exception above
Json.decodeFromString<eu.abra.models.Test>("{\"plan\": {\"name\": \"basic\"}}") // This is deserialized correctly

Solution

  • If I understand correctly, your JSON can be

    {"plan": "some name"}
    

    or

    {"plan": {"name": "some name"}}
    

    And you want to serialise both formats into a Test object with plan being the string "some name".

    Your approach doesn't work because after the decoder throws the exception when it fails to decode the expected object, it already consumes the first token of the JSON string ("). By the time you catch the exception and call decodeString, it will read the character after the ", which is not what it expects.


    You can do this using a JsonContentPolymorphicSerializer - choose the correct serialiser based on whether the JSON element encountered is a JSON object or a JSON string.

    object PlanSerializer : JsonContentPolymorphicSerializer<String>(String::class) {
        override fun selectDeserializer(element: JsonElement): DeserializationStrategy<String> =
            if (element is JsonPrimitive && element.isString) {
                String.serializer()
            } else {
                PlanAsStringSerializer
            }
    
        private object PlanAsStringSerializer : KSerializer<String> {
            override val descriptor: SerialDescriptor = Plan.serializer().descriptor
            override fun deserialize(decoder: Decoder): String =
                decoder.decodeSerializableValue(Plan.serializer()).name
            override fun serialize(encoder: Encoder, value: String) = error("Not supported")
        }
    }
    
    @Serializable
    data class Plan(
        val name: String
    )
    

    If you want to fall back to null when something goes wrong, you can decode a JsonElement instead.

    class PlanSerializer : KSerializer<String?> {
        override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("StringSerializer", PrimitiveKind.STRING)
    
        override fun deserialize(decoder: Decoder): String? {
            return runCatching {
                when(val plan = decoder.decodeSerializableValue(JsonElement.serializer())) {
                    is JsonPrimitive -> plan.takeIf { it.isString }?.content
                    is JsonObject -> Json.decodeFromJsonElement<Plan>(plan).name
                    else -> null
                }
            }.getOrNull()
        }
    
        @OptIn(ExperimentalSerializationApi::class)
        override fun serialize(encoder: Encoder, value: String?) {
            return value?.let {
                encoder.encodeString(value)
            } ?: encoder.encodeNull()
        }
    }