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

@Contextual on type that declared with @Serializable has no effect

Open ssttkkl opened this issue 2 years ago • 7 comments

Describe the bug I have such a data class, and I want to serialize the time field (type is kotlinx.datetime.LocalDateTime) with a contextual serializer.

@Serializable
data class Data(
    val time: @Contextual LocalDateTime
)

Below shows how I registered a contextual serializer for LocalDateTime (expect to use format yyyy-MM-dd HH:mm:ss):

object LocalDateTimeSerializer : KSerializer<LocalDateTime> {
    private val delegate = String.serializer()
    override val descriptor: SerialDescriptor = delegate.descriptor

    override fun serialize(encoder: Encoder, value: LocalDateTime) {
        val str = "${value.year}-${value.monthNumber}-${value.dayOfMonth} ${value.hour}:${value.minute}:${value.second}"
        delegate.serialize(encoder, str)
    }

    override fun deserialize(decoder: Decoder): LocalDateTime {
        val str = delegate.deserialize(decoder)
        val res = Regex("^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})\$").matchEntire(str)
            ?: throw IllegalArgumentException("invalid format: $str")
        val year = res.groupValues[1].toInt()
        val month = res.groupValues[2].toInt()
        val day = res.groupValues[3].toInt()
        val hour = res.groupValues[4].toInt()
        val minute = res.groupValues[5].toInt()
        val second = res.groupValues[6].toInt()
        return LocalDateTime(year, month, day, hour, minute, second, 0)
    }
}

val json = Json {
  serializersModule = SerializersModule {
      contextual<LocalDateTime>(LocalDateTimeSerializer)
  }
}

But it doesn't work as expected:

val text = json.encodeToString(Data2(LocalDateTime(2023, 10, 31, 11, 54, 0, 0)))
println(text)
// output: {"time":"2023-10-31T11:54"}

If I put @Contextual on the property, it works well.

@Serializable
data class Data(
    @Contextual
    val time: LocalDateTime
)

val text = json.encodeToString(Data2(LocalDateTime(2023, 10, 31, 11, 54, 0, 0)))
println(text)
// output: {"time":"2023-10-31 11:54:00"}

Environment

  • Kotlin version: 1.9.0
  • Library version: 1.6.0
  • Kotlin platforms: JVM
  • Gradle version: 7.4.1
  • kotlinx-datetime: 0.4.1

ssttkkl avatar Oct 31 '23 03:10 ssttkkl

Don't directly use the delegate descriptor as some formats cache based on the serialname, this may also be a problem with contextual. Also if you are just wanting to serialize string primitives you don't need a delegate. Also note that if a property is not contextual the static serializer will be used which is not the one you provided, but the one attached to LocalDateTime. The "best" solution is probably to create a typealias that specifies the @Serialize(LocalDateTimeSerializer::class) annotation.

pdvrieze avatar Oct 31 '23 08:10 pdvrieze

I think this is just a little bug. Using @Contextual on a property is a more correct way. @Contextual on types is meant to be used inside generics, e.g. List<@Contextual LocalDateTime>

sandwwraith avatar Oct 31 '23 09:10 sandwwraith

As an additional use case I wrote a custom deserializer to deserialize a configuration file. This Configuration file has certain domain specific prefixes to specify whether or not the value is a real value or a key for an environment variable.

So for example (in toml) I may have a literal string like

bucket = "dronda"

and string that I want to pull an environment variable of

access_key_id = "env:ACCESS_KEY_ID"

I created a special typealias for this called ConfigString and wrote a custom serializer for it.

 typealias ConfigString = @Contextual String

class ConfigStringSerializer(
    private val envDecoder: EnvDecoder,
) : KSerializer<ConfigString> {
    override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ConfigString", PrimitiveKind.STRING)

    override fun deserialize(decoder: Decoder): String {
        val rawString = decoder.decodeString()
        return envDecoder.decode(rawString)
    }

    override fun serialize(encoder: Encoder, value: String) {
        error("Should not be serialized")
    }
}

Note that in this custom serializer I take in a constructor parameter.

This consists of a pretty straightforward interface and implementation

interface EnvDecoder {
    fun decode(rawString: String): String
}

object DefaultEnvDecoder : EnvDecoder {
    private const val ENV_PREFIX = "env:"
    override fun decode(rawString: String): String {
        return if (rawString.startsWith(ENV_PREFIX)) {
            val envKey = rawString.substringAfter(ENV_PREFIX)
            System.getenv(envKey) ?: error("EnvKey $envKey not found in environment")
        } else {
            rawString
        }
    }
}

In theory we don't really need the ConfigStringSerializer to take a constructor param, but I do this because I have multiple different configurations for different environments (DEV, PREPROD, PROD, TEST, etc). I want to make sure that each configuration file is deserializable. However, depending on the environment I may have some strings specified as env in one enviornment and some as normal strings in another.

Therefore for my test I pass this implementation in my tests:

object MockEnvDecoder : EnvDecoder {
    override fun decode(rawString: String): String {
        return rawString
    }
}

Currently this does not work due to this bug. I need to add contextual to each and every property like this:

@Serializable
data class DatabaseConfig(
    @Contextual
    val driver: String,
    @Contextual
    val url: String
)

Instead of doing:

@Serializable
data class DatabaseConfig(
    val driver: ConfigString,
    val url: ConfigString
)

I created a reproducer with tests to prove the issue: https://github.com/dronda-t/kotlinxserialization-contextual-bug

Perhaps this is a misuse of the library, if so please let me know, especially if there is a better method of accomplishing this. Thanks!

dronda-t avatar Mar 01 '24 19:03 dronda-t

Hm, it seems logical to have typealias ConfigString = @Contextual String given that we allow smth like typealias ConfigString = @Serializable(ConfigSerializer::class) String

sandwwraith avatar Mar 12 '24 11:03 sandwwraith

I think you can try to use @Serializable(ContextualSerializer::class) as a workaround instead of @Contextual on types.

sandwwraith avatar Mar 12 '24 11:03 sandwwraith

Thanks that worked!

Although I do get this compiler warning:

Class 'ContextualSerializer<*>', which is serializer for type 'Any', is applied here to type 'ConfigString /* = String */'. This may lead to errors or incorrect behavior

dronda-t avatar Mar 17 '24 00:03 dronda-t