Kotlin / kotlinx.serialization

Kotlin multiplatform / multi-format serialization
Apache License 2.0
5.35k stars 619 forks source link

Customizing serialization (without `@Contextual`) #2279

Open wkornewald opened 1 year ago

wkornewald commented 1 year ago

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

We have lots of modules which have two or three different ZonedDateTime encodings because different backends have different format requirements. How can we avoid plastering the whole code with @Contextual and getting bugs where we forget to add the annotation? (esp. if ZonedDateTime has a default serializer)

Another use-case is that we have individual encrypted fields and we'd like to e.g. set an @Encrypted annotation on them so that the value is pre/post-processed for (de-)serialization.

Describe the solution you'd like

We'd like to have one central place to set the serializer per Json instance. There already is serializersModule, but it's not clear to us why we additionally have to set @Contextual everywhere in the code (or per file). This feels completely unnecessary and just makes the code more error-prone. Is it possible to have something like serializersModule without @Contextual somehow?

Ideally, we'd like to be able to inject some mapper function which gets the type and information about the field (so we can lookup potential annotations) and then it can override the default (de-)serialization behavior either fully or pre/post-process the data before/after the default serialization behavior.

sandwwraith commented 1 year ago

kotlinx.serialization uses a compiler plugin, which means that actual serializer for type gets compiled into the generated serialization code. There is no type mapping for it on runtime, as KClass is never retrieved. That's why @Contextual is needed — a special serializer that captures KClass and can perform a lookup in serializersModule.

To ease specifying serializers, I can recommend you one of the techniques from here: https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/serializers.md#specifying-serializers-for-a-file See 'Specifying serializers for a file' or 'Specifying serializer globally using typealias'. There is also @file:UseContextualSerialization.

wkornewald commented 1 year ago

The @file:UseContextualSerialization solution is something we wanted to avoid because if you have tons of files it's too easy to forget.

Also, the compiler plugin could simply treat all non-primitive types as if they had @Contextual (unless there's an explicit serializer defined for that field). Then we'd never need to do this annotation by hand. What would be the problem with this solution?

Alternatively, what about having a simple interface like this

interface SerializationMapper {
    fun serialize(value: Any?, defaultSerialization: SerializationMapper, fieldAnnotations: List<Any>): Any?
}

The default behavior would be contained in the defaultSerialization which you can just delegate to, but you can also return any transformed value. This way we could just do

class ZonedDateTimeMapper(val someFormatSpec: IsoFormatSpec) : SerializationMapper {
    fun serialize(value: Any?, defaultSerialization: SerializationMapper, fieldAnnotations: List<Any>): Any? =
        if (value is ZonedDateTime)
            value.formatToIsoString(someFormatSpec)
        else
            defaultSerialization.serialize(value, defaultSerialization, fieldAnnotations)
}

Though, just having a default @Contextual annotation applied to all fields automatically might be sufficient.

sandwwraith commented 1 year ago

Also, the compiler plugin could simply treat all non-primitive types as if they had @Contextual (unless there's an explicit serializer defined for that field). Then we'd never need to do this annotation by hand. What would be the problem with this solution?

This goes against design principles: given that classes are processed at compile time, we can also report errors about missing serializers at compile time and in the IDE, which is great and important for us. By auto-inserting @Contextual everywhere instead, we lose this ability, and debugging runtime errors about missing serializers usually takes more time. Also, it slows down the serialization process, as every non-primitive property would require lookup in the Map<KClass, KSerializer>.

wkornewald commented 1 year ago

Then how about introducing module-wide serializer mappings and also adding Contextual for types which define a serializer (e.g. so kotlinx-datetime's Instant can be customized without extra annotations)?

I'd rather take a small performance hit than deal with bugs due to missing Contextual. But module wide rules could even solve the performance issue.

shalaga44 commented 4 months ago

Still can't find a way to make the plugin —in enums— use the explicit serializer if it exist, and the default serializer if not without using @Contextual.

my use-case is I have a database persistenceName (maintained by JetBrains/Exposed Custom Column) & dtoName that had to be maintained differently:

enum class ExampleEnum(
    override val dtoName: String,
    override val persistenceName: String
) : EnumDtoName, EnumPersistenceName {
    FIRST(dtoName = "first_on_mobile", persistenceName = "first_on_database"),
    SECOND(dtoName = "second_on_mobile", persistenceName = "second_on_database"),
    THIRD(dtoName = "third_on_mobile", persistenceName = "third_on_database")
}

Any update on this? :')

pdvrieze commented 4 months ago

@shalaga44 There are 2 ways that this behaviour can be achieved: