Skip to content

Allow error handling intervention in sealed class serializer #2841

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
robpridham-bbc opened this issue Oct 23, 2024 · 3 comments
Closed

Allow error handling intervention in sealed class serializer #2841

robpridham-bbc opened this issue Oct 23, 2024 · 3 comments

Comments

@robpridham-bbc
Copy link

robpridham-bbc commented Oct 23, 2024

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

We have sealed classes/interfaces, and we use the in-built polymorphism support, which means we don't have to do anything specific about declaring polymorphic types - the parser implementation follows the code. This is very valuable to us.

We have a resiliency requirement whereby we want to catch exceptions when parsing any one of these subtypes, and return either null or some default type (either is acceptable). This supports a business use case in a plugin architecture context where datamodel-incompatible objects within a particular context should be tolerable and ignored.

Currently this is technically possible, but it seems we have to sacrifice some or all of the benefits of seamless sealed support.

We have explored a lot of possibilities, and the most viable one we have discovered is as follows.

We can write a 'null deserializer' wrapper function:

private fun <T> nullDeserializer(originalSerializer: KSerializer<T>): KSerializer<T?> {
    return object : KSerializer<T?> {
        override fun deserialize(decoder: Decoder): T? {
            return try {
                originalSerializer.deserialize(decoder)
            } catch (e: Exception) {
                null
            }
        }

        override val descriptor: SerialDescriptor get() = originalSerializer.descriptor

        override fun serialize(encoder: Encoder, value: T?) {
            value?.let {
                originalSerializer.serialize(encoder, it)
            }
        }
    }
}

(consider that this could return a sealed class error type instance instead of null if necessary)

and then we can apply it to every type:

    private fun SerializersModuleBuilder.registerContentItemStructure() {
        polymorphic(ContentItem::class) {
            subclass(
                FirstThing::class,
                nullDeserializer(FirstThing.serializer()) as KSerializer<FirstThing>
            )
            subclass(
                OtherThing::class,
                nullDeserializer(OtherThing.serializer()) as KSerializer<OtherThing>
            )
            ...

However, we incur various costs here:

  • I think we have to switch to @Polymorphic annotations and make sure we add them comprehensively
  • We are exposed to developer mistakes in the above function, i.e. not adding a subclass() call for their new type

Describe the solution you'd like

We would like to be able to override or otherwise intervene in the plugin-generated polymorphic serializer for our sealed types.

This could possibly be achieved in a variety of ways:

  • @KeepGeneratedSerializer support for this type - currently it is "not allowed on classes involved in polymorphic serialization: interfaces, sealed classes"
  • Add an exception handler in the same style as we can apply the polymorphicDefaultDeserializer() configuration
  • Provide a clearer path to us overriding (I assume) SealedClassSerializer whilst leveraging as much as possible of the plugin-generated tree

I don't know which of these, if any, might be most aligned to the roadmap.

Thanks for reading - advice welcome!

@valeriyo
Copy link

As it stands now, the generated SealedClassSerializer cannot be wrapped (See #2838)

@sandwwraith
Copy link
Member

I think it's the same as #2721 / #1865

However, please note that we officially do not support catching exceptions in decoders — see 'Exception safety' section here: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-core/kotlinx.serialization.encoding/-decoder/. This is because state of decoder after throwing exception is undefined.

@sandwwraith sandwwraith closed this as not planned Won't fix, can't repro, duplicate, stale Nov 5, 2024
@robpridham-bbc
Copy link
Author

So, I did find a provisional answer to this, which is to introduce an extra layer into object inheritance. This means we can override a higher level of parsing, and we can use decoder.decodeSerializableValue to invoke the original polymorphic parser without having to interfere with it:

class ContentItemSafetySerializer : KSerializer<ContentItemOrException> {
    override val descriptor: SerialDescriptor
        get() {
            return ContentItem.serializer().descriptor
        }
    
    override fun deserialize(decoder: Decoder): ContentItemOrException {
        return try {
            decoder.decodeSerializableValue(ContentItem.serializer())
        } catch (e: Exception) {
            ContentItemException(e)
        }
    }

    override fun serialize(encoder: Encoder, value: ContentItemOrException) {
        TODO("Not yet implemented")
    }
}

@Keep
@Serializable(with=ContentItemSafetySerializer::class)
sealed interface ContentItemOrException

@Keep
class ContentItemException(val t: Throwable) : ContentItemOrException

@Keep
@Serializable
sealed interface ContentItem : ContentItemOrException

@Serializable
@SerialName("ContentResponse")
class ContentResponse(
    val items: List<ContentItemOrException?>,
...

However I also note the comment above about exception safety and we will need to explore whether this is too much of a liability in our typical circumstances.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants