Skip to content

Support encode/decode java.time.Duration and ConfigMemorySize in HOCON #2094

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

Merged
merged 2 commits into from
Feb 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions formats/hocon/api/kotlinx-serialization-hocon.api
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,34 @@ public final class kotlinx/serialization/hocon/HoconBuilder {
public final fun setUseConfigNamingConvention (Z)V
}

public abstract interface class kotlinx/serialization/hocon/HoconDecoder {
public abstract fun decodeConfigValue (Lkotlin/jvm/functions/Function2;)Ljava/lang/Object;
}

public abstract interface class kotlinx/serialization/hocon/HoconEncoder {
public abstract fun encodeConfigValue (Lcom/typesafe/config/ConfigValue;)V
}

public final class kotlinx/serialization/hocon/HoconKt {
public static final fun Hocon (Lkotlinx/serialization/hocon/Hocon;Lkotlin/jvm/functions/Function1;)Lkotlinx/serialization/hocon/Hocon;
public static synthetic fun Hocon$default (Lkotlinx/serialization/hocon/Hocon;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lkotlinx/serialization/hocon/Hocon;
}

public final class kotlinx/serialization/hocon/serializers/ConfigMemorySizeSerializer : kotlinx/serialization/KSerializer {
public static final field INSTANCE Lkotlinx/serialization/hocon/serializers/ConfigMemorySizeSerializer;
public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/typesafe/config/ConfigMemorySize;
public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object;
public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor;
public fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/typesafe/config/ConfigMemorySize;)V
public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V
}

public final class kotlinx/serialization/hocon/serializers/JavaDurationSerializer : kotlinx/serialization/KSerializer {
public static final field INSTANCE Lkotlinx/serialization/hocon/serializers/JavaDurationSerializer;
public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object;
public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/time/Duration;
public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor;
public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V
public fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/time/Duration;)V
}

36 changes: 21 additions & 15 deletions formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/Hocon.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@
package kotlinx.serialization.hocon

import com.typesafe.config.*
import kotlin.time.*
import kotlinx.serialization.*
import kotlinx.serialization.builtins.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*
import kotlinx.serialization.encoding.CompositeDecoder.Companion.DECODE_DONE
import kotlinx.serialization.hocon.internal.SuppressAnimalSniffer
import kotlinx.serialization.hocon.internal.*
import kotlinx.serialization.hocon.serializers.*
import kotlinx.serialization.internal.*
import kotlinx.serialization.modules.*
import kotlin.time.*

/**
* Allows [deserialization][decodeFromConfig]
Expand All @@ -34,6 +36,12 @@ import kotlinx.serialization.modules.*
* 24.hours -> 1 d
* All restrictions on the maximum and minimum duration are specified in [Duration].
*
* It is also possible to encode and decode [java.time.Duration] and [com.typesafe.config.ConfigMemorySize]
* with provided serializers: [JavaDurationSerializer] and [ConfigMemorySizeSerializer].
* Because these types are not @[Serializable] by default,
* one has to apply these serializers manually — either via @Serializable(with=...) / @file:UseSerializers
* or using [Contextual] and [SerializersModule] mechanisms.
*
* @param [useConfigNamingConvention] switches naming resolution to config naming convention (hyphen separated).
* @param serializersModule A [SerializersModule] which should contain registered serializers
* for [Contextual] and [Polymorphic] serialization, if you have any.
Expand Down Expand Up @@ -79,7 +87,7 @@ public sealed class Hocon(
@ExperimentalSerializationApi
public companion object Default : Hocon(false, false, false, "type", EmptySerializersModule())

private abstract inner class ConfigConverter<T> : TaggedDecoder<T>() {
private abstract inner class ConfigConverter<T> : TaggedDecoder<T>(), HoconDecoder {
override val serializersModule: SerializersModule
get() = this@Hocon.serializersModule

Expand All @@ -102,15 +110,9 @@ public sealed class Hocon(
private fun getTaggedNumber(tag: T) = validateAndCast<Number>(tag)

@SuppressAnimalSniffer
protected fun <E> decodeDurationInHoconFormat(tag: T): E {
protected fun <E> decodeDuration(tag: T): E {
@Suppress("UNCHECKED_CAST")
return getValueFromTaggedConfig(tag) { conf, path ->
try {
conf.getDuration(path).toKotlinDuration()
} catch (e: ConfigException) {
throw SerializationException("Value at $path cannot be read as kotlin.Duration because it is not a valid HOCON duration value", e)
}
} as E
return getValueFromTaggedConfig(tag) { conf, path -> conf.decodeJavaDuration(path).toKotlinDuration() } as E
}

override fun decodeTaggedString(tag: T) = validateAndCast<String>(tag)
Expand All @@ -137,6 +139,10 @@ public sealed class Hocon(
val s = validateAndCast<String>(tag)
return enumDescriptor.getElementIndexOrThrow(s)
}

override fun <E> decodeConfigValue(extractValueAtPath: (Config, String) -> E): E =
getValueFromTaggedConfig(currentTag, extractValueAtPath)

}

private inner class ConfigReader(val conf: Config) : ConfigConverter<String>() {
Expand Down Expand Up @@ -166,7 +172,7 @@ public sealed class Hocon(

override fun <T> decodeSerializableValue(deserializer: DeserializationStrategy<T>): T {
return when {
deserializer.descriptor == Duration.serializer().descriptor -> decodeDurationInHoconFormat(currentTag)
deserializer.descriptor.isDuration -> decodeDuration(currentTag)
deserializer !is AbstractPolymorphicSerializer<*> || useArrayPolymorphism -> deserializer.deserialize(this)
else -> {
val config = if (currentTagOrNull != null) conf.getConfig(currentTag) else conf
Expand Down Expand Up @@ -203,8 +209,8 @@ public sealed class Hocon(
private inner class ListConfigReader(private val list: ConfigList) : ConfigConverter<Int>() {
private var ind = -1

override fun <T> decodeSerializableValue(deserializer: DeserializationStrategy<T>): T = when (deserializer.descriptor) {
Duration.serializer().descriptor -> decodeDurationInHoconFormat(ind)
override fun <T> decodeSerializableValue(deserializer: DeserializationStrategy<T>): T = when {
deserializer.descriptor.isDuration -> decodeDuration(ind)
else -> super.decodeSerializableValue(deserializer)
}

Expand Down Expand Up @@ -243,8 +249,8 @@ public sealed class Hocon(

private val indexSize = values.size * 2

override fun <T> decodeSerializableValue(deserializer: DeserializationStrategy<T>): T = when (deserializer.descriptor) {
Duration.serializer().descriptor -> decodeDurationInHoconFormat(ind)
override fun <T> decodeSerializableValue(deserializer: DeserializationStrategy<T>): T = when {
deserializer.descriptor.isDuration -> decodeDuration(ind)
else -> super.decodeSerializableValue(deserializer)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package kotlinx.serialization.hocon

import com.typesafe.config.Config
import kotlinx.serialization.ExperimentalSerializationApi

/**
* Decoder used by Hocon during deserialization.
* This interface allows to call methods from the Lightbend/config library on the [Config] object to intercept default deserialization process.
*
* Usage example (nested config serialization):
* ```
* @Serializable
* data class Example(
* @Serializable(NestedConfigSerializer::class)
* val d: Config
* )
* object NestedConfigSerializer : KSerializer<Config> {
* override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("package.Config", PrimitiveKind.STRING)
*
* override fun deserialize(decoder: Decoder): Config =
* if (decoder is HoconDecoder) decoder.decodeConfigValue { conf, path -> conf.getConfig(path) }
* else throw SerializationException("This class can be decoded only by Hocon format")
*
* override fun serialize(encoder: Encoder, value: Config) {
* if (encoder is AbstractHoconEncoder) encoder.encodeConfigValue(value.root())
* else throw SerializationException("This class can be encoded only by Hocon format")
* }
* }
*
* val nestedConfig = ConfigFactory.parseString("nested { value = \"test\" }")
* val globalConfig = Hocon.encodeToConfig(Example(nestedConfig)) // d: { nested: { value = "test" } }
* val newNestedConfig = Hocon.decodeFromConfig(Example.serializer(), globalConfig)
* ```
*/
@ExperimentalSerializationApi
sealed interface HoconDecoder {

/**
* Decodes the value at the current path from the input.
* Allows to call methods on a [Config] instance.
*
* @param E type of value
* @param extractValueAtPath lambda for extracting value, where conf - original config object, path - current path expression being decoded.
* @return result of lambda execution
*/
fun <E> decodeConfigValue(extractValueAtPath: (conf: Config, path: String) -> E): E
}
Original file line number Diff line number Diff line change
@@ -1,169 +1,43 @@
/*
* Copyright 2017-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
*/

package kotlinx.serialization.hocon

import com.typesafe.config.*
import kotlin.time.*
import kotlinx.serialization.*
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*
import kotlinx.serialization.internal.*
import kotlinx.serialization.modules.*

@ExperimentalSerializationApi
internal abstract class AbstractHoconEncoder(
private val hocon: Hocon,
private val valueConsumer: (ConfigValue) -> Unit,
) : NamedValueEncoder() {

override val serializersModule: SerializersModule
get() = hocon.serializersModule

private var writeDiscriminator: Boolean = false

override fun elementName(descriptor: SerialDescriptor, index: Int): String {
return descriptor.getConventionElementName(index, hocon.useConfigNamingConvention)
}

override fun composeName(parentName: String, childName: String): String = childName

protected abstract fun encodeTaggedConfigValue(tag: String, value: ConfigValue)
protected abstract fun getCurrent(): ConfigValue

override fun encodeTaggedValue(tag: String, value: Any) = encodeTaggedConfigValue(tag, configValueOf(value))
override fun encodeTaggedNull(tag: String) = encodeTaggedConfigValue(tag, configValueOf(null))
override fun encodeTaggedChar(tag: String, value: Char) = encodeTaggedString(tag, value.toString())

override fun encodeTaggedEnum(tag: String, enumDescriptor: SerialDescriptor, ordinal: Int) {
encodeTaggedString(tag, enumDescriptor.getElementName(ordinal))
}

override fun shouldEncodeElementDefault(descriptor: SerialDescriptor, index: Int): Boolean = hocon.encodeDefaults

override fun <T> encodeSerializableValue(serializer: SerializationStrategy<T>, value: T) {
when {
serializer.descriptor == Duration.serializer().descriptor -> encodeDuration(value as Duration)
serializer !is AbstractPolymorphicSerializer<*> || hocon.useArrayPolymorphism -> serializer.serialize(this, value)
else -> {
@Suppress("UNCHECKED_CAST")
val casted = serializer as AbstractPolymorphicSerializer<Any>
val actualSerializer = casted.findPolymorphicSerializer(this, value as Any)
writeDiscriminator = true

actualSerializer.serialize(this, value)
}
}
}

override fun beginStructure(descriptor: SerialDescriptor): CompositeEncoder {
val consumer =
if (currentTagOrNull == null) valueConsumer
else { value -> encodeTaggedConfigValue(currentTag, value) }
val kind = descriptor.hoconKind(hocon.useArrayPolymorphism)

return when {
kind.listLike -> HoconConfigListEncoder(hocon, consumer)
kind.objLike -> HoconConfigEncoder(hocon, consumer)
kind == StructureKind.MAP -> HoconConfigMapEncoder(hocon, consumer)
else -> this
}.also { encoder ->
if (writeDiscriminator) {
encoder.encodeTaggedString(hocon.classDiscriminator, descriptor.serialName)
writeDiscriminator = false
}
}
}

override fun endEncode(descriptor: SerialDescriptor) {
valueConsumer(getCurrent())
}

private fun configValueOf(value: Any?) = ConfigValueFactory.fromAnyRef(value)

private fun encodeDuration(value: Duration) {
val result = value.toComponents { seconds, nanoseconds ->
when {
nanoseconds == 0 -> {
if (seconds % 60 == 0L) { // minutes
if (seconds % 3600 == 0L) { // hours
if (seconds % 86400 == 0L) { // days
"${seconds / 86400} d"
} else {
"${seconds / 3600} h"
}
} else {
"${seconds / 60} m"
}
} else {
"$seconds s"
}
}
nanoseconds % 1_000_000 == 0 -> "${seconds * 1_000 + nanoseconds / 1_000_000} ms"
nanoseconds % 1_000 == 0 -> "${seconds * 1_000_000 + nanoseconds / 1_000} us"
else -> "${value.inWholeNanoseconds} ns"
}
}
encodeString(result)
}
}

@ExperimentalSerializationApi
internal class HoconConfigEncoder(hocon: Hocon, configConsumer: (ConfigValue) -> Unit) :
AbstractHoconEncoder(hocon, configConsumer) {

private val configMap = mutableMapOf<String, ConfigValue>()

override fun encodeTaggedConfigValue(tag: String, value: ConfigValue) {
configMap[tag] = value
}

override fun getCurrent(): ConfigValue = ConfigValueFactory.fromMap(configMap)
}

@ExperimentalSerializationApi
internal class HoconConfigListEncoder(hocon: Hocon, configConsumer: (ConfigValue) -> Unit) :
AbstractHoconEncoder(hocon, configConsumer) {

private val values = mutableListOf<ConfigValue>()

override fun elementName(descriptor: SerialDescriptor, index: Int): String = index.toString()

override fun encodeTaggedConfigValue(tag: String, value: ConfigValue) {
values.add(tag.toInt(), value)
}

override fun getCurrent(): ConfigValue = ConfigValueFactory.fromIterable(values)
}

import com.typesafe.config.ConfigValue
import kotlinx.serialization.ExperimentalSerializationApi

/**
* Encoder used by Hocon during serialization.
* This interface allows intercepting serialization process and insertion of arbitrary [ConfigValue] into the output.
*
* Usage example (nested config serialization):
* ```
* @Serializable
* data class Example(
* @Serializable(NestedConfigSerializer::class)
* val d: Config
* )
* object NestedConfigSerializer : KSerializer<Config> {
* override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("package.Config", PrimitiveKind.STRING)
*
* override fun deserialize(decoder: Decoder): Config =
* if (decoder is HoconDecoder) decoder.decodeConfigValue { conf, path -> conf.getConfig(path) }
* else throw SerializationException("This class can be decoded only by Hocon format")
*
* override fun serialize(encoder: Encoder, value: Config) {
* if (encoder is HoconEncoder) encoder.encodeConfigValue(value.root())
* else throw SerializationException("This class can be encoded only by Hocon format")
* }
* }
* val nestedConfig = ConfigFactory.parseString("nested { value = \"test\" }")
* val globalConfig = Hocon.encodeToConfig(Example(nestedConfig)) // d: { nested: { value = "test" } }
* val newNestedConfig = Hocon.decodeFromConfig(Example.serializer(), globalConfig)
* ```
*/
@ExperimentalSerializationApi
internal class HoconConfigMapEncoder(hocon: Hocon, configConsumer: (ConfigValue) -> Unit) :
AbstractHoconEncoder(hocon, configConsumer) {

private val configMap = mutableMapOf<String, ConfigValue>()

private lateinit var key: String
private var isKey: Boolean = true

override fun encodeTaggedConfigValue(tag: String, value: ConfigValue) {
if (isKey) {
key = when (value.valueType()) {
ConfigValueType.OBJECT, ConfigValueType.LIST -> throw InvalidKeyKindException(value)
else -> value.unwrappedNullable().toString()
}
isKey = false
} else {
configMap[key] = value
isKey = true
}
}

override fun getCurrent(): ConfigValue = ConfigValueFactory.fromMap(configMap)

// Without cast to `Any?` Kotlin will assume unwrapped value as non-nullable by default
// and will call `Any.toString()` instead of extension-function `Any?.toString()`.
// We can't cast value in place using `(value.unwrapped() as Any?).toString()` because of warning "No cast needed".
private fun ConfigValue.unwrappedNullable(): Any? = unwrapped()
sealed interface HoconEncoder {

/**
* Appends the given [ConfigValue] element to the current output.
*
* @param value to insert
*/
fun encodeConfigValue(value: ConfigValue)
}
Loading