@Contextual on type that declared with @Serializable has no effect
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
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.
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>
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!
Hm, it seems logical to have typealias ConfigString = @Contextual String given that we allow smth like typealias ConfigString = @Serializable(ConfigSerializer::class) String
I think you can try to use @Serializable(ContextualSerializer::class) as a workaround instead of @Contextual on types.
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