Skip to content

Commit

Permalink
Introduce HoconEncoder and HoconDecoder interfaces (#2094)
Browse files Browse the repository at this point in the history
Analogues for JsonEncoder/Decoder should ease writing hocon-specific serializers for various classes.

Add java.time.Duration and ConfigMemorySize serializers for HOCON.

---------

Co-authored-by: Leonid Startsev <sandwwraith@users.noreply.github.com>
  • Loading branch information
alexmihailov and sandwwraith authored Feb 23, 2023
1 parent 90113a9 commit acb0988
Show file tree
Hide file tree
Showing 13 changed files with 827 additions and 184 deletions.
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

0 comments on commit acb0988

Please sign in to comment.