Kotlin / kotlinx.serialization

Kotlin multiplatform / multi-format serialization
Apache License 2.0
5.41k stars 620 forks source link

Custom parsing help #2815

Closed loseryc closed 1 month ago

loseryc commented 1 month ago

Describe the question

I have a data class, in which the name field is defined as List<String>, but when parsing the Json string, sometimes the value corresponding to the name in the Json is a String instead of a List<String>. In this case, we need to process the string into a List with a length of 1. We did this before

@Config(sdk = [Build.VERSION_CODES.P])
@RunWith(RobolectricTestRunner::class)
class ParserObjectMapperTestsV2 {

    @Test
    fun checkPassedStringInPlaceOfArrayList() {
        val parser = Json { isLenient = true }
        val jsonString = "{\"name\":\"test\"}"
        val student = parser.decodeFromString<Student>(
            string = jsonString
        )
        Assert.assertNotNull(student.name)
        Assert.assertEquals(1, student.name?.size)
    }
}

@Serializable
data class Student(
    @Serializable(with = StringListSerializerV2::class)
    var name: List<String>? = null
)

object StringListSerializerV2 : KSerializer<List<String>> {

    override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor(
        serialName = "StringListSerializerV2",
        kind = PrimitiveKind.STRING
    )

    override fun deserialize(decoder: Decoder) = decoder.runCatching {
        decodeSerializableValue(ListSerializer(String.serializer()))
    }.recoverCatching {
        listOf(decoder.decodeString())
    }.getOrElse {
        decoder.decodeNull()
        emptyList()
    }

    override fun serialize(encoder: Encoder, value: List<String>) {
        encoder.encodeSerializableValue(
            serializer = ListSerializer(String.serializer()),
            value = value
        )
    }
}

This worked fine on version kotlinx.serialization:1.5.1, but since upgrading the kotlinx.serialization version to 1.6.0, it no longer works properly, Is there any way to make it work normally?

When use kotlinx.serialization:1.5.1

image

When use kotlinx.serialization:1.6.0

image

Environment

Kotlin version: 1.9.20 Library version: 1.6.0 Kotlin platforms: Android, JVM Gradle version: 8.10 Android Studio Koala | 2024.1.1

pdvrieze commented 1 month ago

You want to intermediated this through json trees. Have a look at JsonTransformingSerializer the example is the opposite of your case.

loseryc commented 1 month ago

You want to intermediated this through json trees. Have a look at JsonTransformingSerializer the example is the opposite of your case.

The field definition in this entity is List<String>, but the value in Json is a String, which is common in the project, so we don't want to perform custom parsing for each entity, but instead want to use a method like the one in the question. @pdvrieze

pdvrieze commented 1 month ago

@loseryc The thing is that your old approach makes assumptions about how the parsing works, and that on parse failure the parser is going to be at the initial state. This is brittle at least, and not guaranteed to work. What does work is to take the approach taken by JsonTransformingSerializer (either use it directly or use your own variant). Basically the following should work:

class JsonOrListSerializer<T>(private val elementSerializer: KSerializer<T>): KSerializer<List<T>> {
    private val listSerializer = ListSerializer(elementSerializer)

    override val descriptor: SerialDescriptor = SerialDescriptor("JsonOrListSerializer", listSerializer.descriptor)

    override fun deserialize(decoder: Decoder): List<T> {
        if (decoder !is JsonDecoder) return listSerializer.deserialize(decoder)

        val elem = decoder.decodeJsonElement()
        return when (elem) {
            is JsonArray -> decoder.json.decodeFromJsonElement(listSerializer, elem)
            else -> listOf(decoder.json.decodeFromJsonElement(elementSerializer, elem))
        }
    }

    override fun serialize(encoder: Encoder, value: List<T>) {
        when {
            // this is to show how this would work
            encoder is JsonEncoder && value.size == 1 ->
                encoder.encodeSerializableValue(elementSerializer, value.single())

            else -> listSerializer.serialize(encoder, value)
        }
    }
}

Note that the serialization applies the "optimization" here just to show how that would work, not to suggest this is the best way to do it. As to the deserialization, I explicitly use the format itself again to decode the list/members (the elementSerializer can be String.serializer()). If instead you want to do it only for strings you can shortcut it using knowledge of "stringness".

loseryc commented 1 month ago
image

@pdvrieze Can do something like this?

pdvrieze commented 1 month ago
image

@pdvrieze Can do something like this?

That would work too (but would fail on other formats than Json).

RahulMarepalli commented 1 month ago

The thing is that your old approach makes assumptions about how the parsing works, and that on parse failure the parser is going to be at the initial state. This is brittle at least, and not guaranteed to work.

@pdvrieze does that mean there has been some significant changes to the way parsing used to happen in version 1.5.1 and 1.6.0 ?

sandwwraith commented 1 month ago

I think we have an example for JsonTransformingSerializer that does almost exactly what you want: https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/json.md#array-wrapping

Regarding exception safety: we do not provide any guarantees on internal Encoder/Decoder state after the exception is thrown, it is stated in the docs (see 'Exception safety' section here: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-core/kotlinx.serialization.encoding/-encoder/). So it may work in one version of kotlinx.serialization, but do not work in the other, since internal state handling may have been changed.