kotlinx.serialization icon indicating copy to clipboard operation
kotlinx.serialization copied to clipboard

How to ignore unknown types in polymorphic deserialization (instead of throwing) ?

Open anjosc opened this issue 3 years ago • 9 comments

What is your use-case and why do you need this feature?

I have several classes that I want to deserialize, that include lists of polymorphic types. I can get it to deserialize correctly known types, but deserializing an unknown type throws an exception. What I really want is that the list includes only known types and unknown types are just filtered out. If this could be done in a generic way would be even better.

sealed interface BaseInterface

interface I1 : BaseInterface {
    fun f1(): String
}

interface I2: BaseInterface {
    fun f2(): String
}

@Serializable
@SerialName("i1")
data class I1Rest(val value: String): I1 {
    override fun f1(): String = value
}

@Serializable
@SerialName("i2")
data class I2Rest(val value: String): I2 {
    override fun f2(): String = value
}

@Serializable
data class SomeClass(val list: List<BaseInterface>)

How can I can correctly deserialize SomeClass with

{ "list": [ {"type": "i1", "value": "v1" }, {"type": "i2", "value": "v2" }, {"type": "i3", "value": "v3" } ] }

If I don't add the i3 type to the json, I can correctly deserialize it using

SerializersModule {
  polymorphic(BaseInterface::class){
    subclass(I1Rest::class)
    subclass(I2Rest::class)
  }
}

But as soon as I include an unknown type, it breaks. Note that I don't want to deserialize unknowns to a default type (that would have to extend the base sealed interface). What I want is to ignore/filter the unknowns. (preferably in a generic way) I would also would like to keep the BaseInterface as an interface and not a class, because I only want to expose interfaces and not concrete classes (this is for a lib)

I already figured out how to make a List serializer that filters out nulls, with code that someone else posted but I'm currently stuck on how to make a polymorphic (de)serializer for this sealed interface structure, that outputs nulls if finds unknown type (instead of throwing) and still handles the type discriminator when both serializing and deserializing (although serializing an unknown type should not be possible)

Describe the solution you'd like I'd like to know how to ignore or map to null unknown types in polymorphic deserialization while still preserving the use of the type class discriminator in both serialization and deserialization

anjosc avatar Mar 30 '22 09:03 anjosc

So, I'm not sure there are better ways to handle this, but this works and is the best I could come up with. Also it would be nice to if there was a way to register a general handler for List<BaseInterface> without having to annotate every such list with a @Serialized(with= ...) but I haven't found a way.

import kotlinx.serialization.*
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows

class PolymorphicTest {

    sealed interface BaseInterface

    interface I1 : BaseInterface {
        fun f1(): String
    }

    interface I2 : BaseInterface {
        fun f2(): String
    }

    @Serializable
    @SerialName("i1")
    data class I1Rest(val value: String) : I1 {
        override fun f1(): String = value
    }

    @Serializable
    @SerialName("i2")
    data class I2Rest(val value: String) : I2 {
        override fun f2(): String = value
    }

    @Serializable
    data class SomeClass(
        @Serializable(with = UnknownBaseInterfaceTypeFilteringListSerializer::class)
        val list: List<BaseInterface>
    )

    val json = Json {
        ignoreUnknownKeys = true
        serializersModule = SerializersModule {
            polymorphic(BaseInterface::class) {
                subclass(I1Rest::class)
                subclass(I2Rest::class)
                defaultDeserializer { UnknownTypeSerializer() }
            }
        }
    }

    class FilteringListSerializer<E>(private val elementSerializer: KSerializer<E>) : KSerializer<List<E>> {
        private val listSerializer = ListSerializer(elementSerializer)
        override val descriptor: SerialDescriptor = listSerializer.descriptor

        override fun serialize(encoder: Encoder, value: List<E>) {
            listSerializer.serialize(encoder, value)
        }

        override fun deserialize(decoder: Decoder): List<E> = with(decoder as JsonDecoder) {
            decodeJsonElement().jsonArray.mapNotNull {
                try {
                    json.decodeFromJsonElement(elementSerializer, it)
                } catch (e: UnknownTypeException) {
                    null
                }
            }
        }
    }

    class UnknownTypeException(message: String) : SerializationException(message)

    open class UnknownTypeSerializer<T> : KSerializer<T> {
        override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Nothing")
        override fun deserialize(decoder: Decoder): T = throw UnknownTypeException("unknown type")
        override fun serialize(encoder: Encoder, value: T) = throw UnknownTypeException("unknown type")
    }

    object UnknownBaseInterfaceTypeFilteringListSerializer : KSerializer<List<BaseInterface>> by FilteringListSerializer(PolymorphicSerializer(BaseInterface::class))

    private fun commonAssertions(input: String) {
        val someClass = json.decodeFromString<SomeClass>(input)
        assertEquals("v1", (someClass.list[0] as I1).f1())
        assertEquals("v2", (someClass.list[1] as I2).f2())
        val expected = """{"list":[{"type":"i1","value":"v1"},{"type":"i2","value":"v2"}]}"""
        assertEquals(expected, json.encodeToString(someClass))
    }

    @Test
    fun `all known types`() {
        val input = """{"list":[{"type":"i1","value":"v1"},{"type":"i2","value":"v2"}]}"""
        commonAssertions(input)
    }

    @Test
    fun `unknown types`() {
        val input = """{ "list": [ {"type": "i1", "value": "v1" }, {"type": "i2", "value": "v2" }, {"type": "i3", "value": "v3" } ] }"""
        commonAssertions(input)
    }

    @Test
    fun `missing type field`() {
        val input = """{ "list": [ {"type": "i1", "value": "v1" }, {"type": "i2", "value": "v2" }, {"not_a_type": "i3", "value": "v3" } ] }"""
        commonAssertions(input)
    }
    @Test
    fun `wrong json element type`() {
        val input = """{ "list": [ {"type": "i1", "value": "v1" }, {"type": "i2", "value": "v2" }, "not an object" ] }"""
        assertThrows<SerializationException> {
            json.decodeFromString<SomeClass>(input)
        }
    }
    @Test
    fun `right type but incomplete data`() {
        val input = """{ "list": [ {"type": "i1", "value": "v1" }, {"type": "i2" } ] }"""
        assertThrows<SerializationException> {
            json.decodeFromString<SomeClass>(input)
        }
    }
}

anjosc avatar Mar 31 '22 08:03 anjosc

You should be able to register a default polymorphic serializer/deserializer in the serializersModuleBuilder (using polymorphicDefaultDeserializer). If you do, you can write the code that determines the return value (you may need to create a dummy child object that you then filter out or something). See: https://github.com/Kotlin/kotlinx.serialization/blob/784892467f292dae27d3544148515795cd8eeee8/core/commonMain/src/kotlinx/serialization/modules/SerializersModuleBuilders.kt#L107-L123

pdvrieze avatar Apr 01 '22 08:04 pdvrieze

One way to do it:

@Serializable
sealed class Event() {

    @SerialName("foo") 
    @Serializable
    data class FooEvent(
        @SerialName("bar") val bar: Bar
    ) : Event()

    @Serializable
    object Unknown : Event()
    
}

val eventSerializationModule = SerializersModule {
    polymorphic(Event::class) {
        default { Event.Unknown.serializer() }
    }
}

val json = Json {
    serializersModule += eventSerializationModule
}

val listOfEvents = json.decodeFromString<List<Event>>(jsonString)

// You can now log and filter unknown events
listOfEvents.forEach {
    if (it is Event.Unknown) {
        log("Unknown event received!")
    } else {
        // Process event
    }
}

vitorhugods avatar Jul 27 '22 16:07 vitorhugods

We would also need to have a solution to achieve this using the annotation "JsonClassDiscriminator"

Maybe taking the default serializer the one that doesn't have the SerialName

@Serializable
@JsonClassDiscriminator("kind")
sealed class Base {

    @Serializable
    @SerialName("i1")
    data class MyClass1(
        @SerialName("data") val data: String?
    ) : Base()

    @Serializable
    @SerialName("i2")
    data class MyClass2(
        @SerialName("data") val data: String?
    ) : Base()

    @Serializable
    data class Unknown(
        @SerialName("data") val data: String?
    ) : Base()
}

Or adding a parameter at the annotation: JsonClassDiscriminator:

@JsonClassDiscriminator("kind", default = Unknown.serializer())

corbella83 avatar Mar 23 '23 15:03 corbella83

+1 On this, could really do with this feature

SamC-Apadmi avatar Aug 10 '23 12:08 SamC-Apadmi

Or maybe would be good to have possibility to revert to null

mvarnagiris avatar Aug 22 '23 12:08 mvarnagiris