Skip to content

Commit

Permalink
Hocon encoder implementation (#1740)
Browse files Browse the repository at this point in the history
Use cases:
- Generate default config. For now, it is possible only to provide default config files from resources.
- Edit config from an app. This feature might be useful for apps having both text config and UI for configuration.

Fixes #1609
  • Loading branch information
osipxd committed Dec 23, 2021
1 parent 77aa167 commit 261490a
Show file tree
Hide file tree
Showing 12 changed files with 552 additions and 150 deletions.
5 changes: 4 additions & 1 deletion formats/hocon/api/kotlinx-serialization-hocon.api
@@ -1,7 +1,8 @@
public abstract class kotlinx/serialization/hocon/Hocon : kotlinx/serialization/SerialFormat {
public static final field Default Lkotlinx/serialization/hocon/Hocon$Default;
public synthetic fun <init> (ZZLjava/lang/String;Lkotlinx/serialization/modules/SerializersModule;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
public synthetic fun <init> (ZZZLjava/lang/String;Lkotlinx/serialization/modules/SerializersModule;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun decodeFromConfig (Lkotlinx/serialization/DeserializationStrategy;Lcom/typesafe/config/Config;)Ljava/lang/Object;
public final fun encodeToConfig (Lkotlinx/serialization/SerializationStrategy;Ljava/lang/Object;)Lcom/typesafe/config/Config;
public fun getSerializersModule ()Lkotlinx/serialization/modules/SerializersModule;
}

Expand All @@ -10,10 +11,12 @@ public final class kotlinx/serialization/hocon/Hocon$Default : kotlinx/serializa

public final class kotlinx/serialization/hocon/HoconBuilder {
public final fun getClassDiscriminator ()Ljava/lang/String;
public final fun getEncodeDefaults ()Z
public final fun getSerializersModule ()Lkotlinx/serialization/modules/SerializersModule;
public final fun getUseArrayPolymorphism ()Z
public final fun getUseConfigNamingConvention ()Z
public final fun setClassDiscriminator (Ljava/lang/String;)V
public final fun setEncodeDefaults (Z)V
public final fun setSerializersModule (Lkotlinx/serialization/modules/SerializersModule;)V
public final fun setUseArrayPolymorphism (Z)V
public final fun setUseConfigNamingConvention (Z)V
Expand Down
14 changes: 3 additions & 11 deletions formats/hocon/build.gradle
Expand Up @@ -12,17 +12,9 @@ compileKotlin {
}
}

configurations {
apiElements {
attributes {
attribute(TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, 8)
}
}
runtimeElements {
attributes {
attribute(TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, 8)
}
}
java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}


Expand Down
98 changes: 56 additions & 42 deletions formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/Hocon.kt
Expand Up @@ -25,24 +25,45 @@ import kotlinx.serialization.modules.*
*/
@ExperimentalSerializationApi
public sealed class Hocon(
internal val useConfigNamingConvention: Boolean,
internal val useArrayPolymorphism: Boolean,
internal val classDiscriminator: String,
override val serializersModule: SerializersModule
internal val encodeDefaults: Boolean,
internal val useConfigNamingConvention: Boolean,
internal val useArrayPolymorphism: Boolean,
internal val classDiscriminator: String,
override val serializersModule: SerializersModule,
) : SerialFormat {

/**
* Decodes the given [config] into a value of type [T] using the given serializer.
*/
@ExperimentalSerializationApi
public fun <T> decodeFromConfig(deserializer: DeserializationStrategy<T>, config: Config): T =
ConfigReader(config).decodeSerializableValue(deserializer)

/**
* The default instance of Hocon parser.
* Encodes the given [value] into a [Config] using the given [serializer].
* @throws SerializationException If list or primitive type passed as a [value].
*/
@ExperimentalSerializationApi
public companion object Default : Hocon(false, false, "type", EmptySerializersModule) {
private val NAMING_CONVENTION_REGEX by lazy { "[A-Z]".toRegex() }
public fun <T> encodeToConfig(serializer: SerializationStrategy<T>, value: T): Config {
lateinit var configValue: ConfigValue
val encoder = HoconConfigEncoder(this) { configValue = it }
encoder.encodeSerializableValue(serializer, value)

if (configValue !is ConfigObject) {
throw SerializationException(
"Value of type '${configValue.valueType()}' can't be used at the root of HOCON Config. " +
"It should be either object or map."
)
}
return (configValue as ConfigObject).toConfig()
}

/**
* The default instance of Hocon parser.
*/
@ExperimentalSerializationApi
public companion object Default : Hocon(false, false, false, "type", EmptySerializersModule)

private abstract inner class ConfigConverter<T> : TaggedDecoder<T>() {
override val serializersModule: SerializersModule
get() = this@Hocon.serializersModule
Expand All @@ -59,8 +80,7 @@ public sealed class Hocon(
}
} catch (e: ConfigException) {
val configOrigin = e.origin()
val requiredType = E::class.simpleName
throw SerializationException("${configOrigin.description()} required to be of type $requiredType")
throw ConfigValueTypeCastException<E>(configOrigin)
}
}

Expand Down Expand Up @@ -109,13 +129,7 @@ public sealed class Hocon(
if (parentName.isEmpty()) childName else "$parentName.$childName"

override fun SerialDescriptor.getTag(index: Int): String =
composeName(currentTagOrNull ?: "", getConventionElementName(index))

private fun SerialDescriptor.getConventionElementName(index: Int): String {
val originalName = getElementName(index)
return if (!useConfigNamingConvention) originalName
else originalName.replace(NAMING_CONVENTION_REGEX) { "-${it.value.lowercase()}" }
}
composeName(currentTagOrNull.orEmpty(), getConventionElementName(index, useConfigNamingConvention))

override fun decodeNotNullMark(): Boolean {
// Tag might be null for top-level deserialization
Expand All @@ -133,24 +147,14 @@ public sealed class Hocon(
val reader = ConfigReader(config)
val type = reader.decodeTaggedString(classDiscriminator)
val actualSerializer = deserializer.findPolymorphicSerializerOrNull(reader, type)
?: throwSerializerNotFound(type)
?: throw SerializerNotFoundException(type)

@Suppress("UNCHECKED_CAST")
return (actualSerializer as DeserializationStrategy<T>).deserialize(reader)
}

private fun throwSerializerNotFound(type: String?): Nothing {
val suffix = if (type == null) "missing class discriminator ('null')" else "class discriminator '$type'"
throw SerializationException("Polymorphic serializer was not found for $suffix")
}

override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder {
val kind = when (descriptor.kind) {
is PolymorphicKind -> {
if (useArrayPolymorphism) StructureKind.LIST else StructureKind.MAP
}
else -> descriptor.kind
}
val kind = descriptor.hoconKind(useArrayPolymorphism)

return when {
kind.listLike -> ListConfigReader(conf.getList(currentTag))
Expand Down Expand Up @@ -239,28 +243,31 @@ public sealed class Hocon(
throw SerializationException("$serialName does not contain element with name '$name'")
return index
}

private val SerialKind.listLike get() = this == StructureKind.LIST || this is PolymorphicKind
private val SerialKind.objLike get() = this == StructureKind.CLASS || this == StructureKind.OBJECT
}

/**
* Decodes the given [config] into a value of type [T] using a deserialize retrieved
* from reified type parameter.
* Decodes the given [config] into a value of type [T] using a deserializer retrieved
* from the reified type parameter.
*/
@ExperimentalSerializationApi
public inline fun <reified T> Hocon.decodeFromConfig(config: Config): T =
decodeFromConfig(serializersModule.serializer(), config)

/**
* Encodes the given [value] of type [T] into a [Config] using a serializer retrieved
* from the reified type parameter.
*/
@ExperimentalSerializationApi
public inline fun <reified T> Hocon.encodeToConfig(value: T): Config =
encodeToConfig(serializersModule.serializer(), value)

/**
* Creates an instance of [Hocon] configured from the optionally given [Hocon instance][from]
* and adjusted with [builderAction].
*/
@ExperimentalSerializationApi
public fun Hocon(from: Hocon = Hocon, builderAction: HoconBuilder.() -> Unit): Hocon {
val builder = HoconBuilder(from)
builder.builderAction()
return HoconImpl(builder.useConfigNamingConvention, builder.useArrayPolymorphism, builder.classDiscriminator, builder.serializersModule)
return HoconImpl(HoconBuilder(from).apply(builderAction))
}

/**
Expand All @@ -273,6 +280,12 @@ public class HoconBuilder internal constructor(hocon: Hocon) {
*/
public var serializersModule: SerializersModule = hocon.serializersModule

/**
* Specifies whether default values of Kotlin properties should be encoded.
* `false` by default.
*/
public var encodeDefaults: Boolean = hocon.encodeDefaults

/**
* Switches naming resolution to config naming convention: hyphen separated.
*/
Expand All @@ -293,9 +306,10 @@ public class HoconBuilder internal constructor(hocon: Hocon) {
}

@OptIn(ExperimentalSerializationApi::class)
private class HoconImpl(
useConfigNamingConvention: Boolean,
useArrayPolymorphism: Boolean,
classDiscriminator: String,
serializersModule: SerializersModule
) : Hocon(useConfigNamingConvention, useArrayPolymorphism, classDiscriminator, serializersModule)
private class HoconImpl(hoconBuilder: HoconBuilder) : Hocon(
encodeDefaults = hoconBuilder.encodeDefaults,
useConfigNamingConvention = hoconBuilder.useConfigNamingConvention,
useArrayPolymorphism = hoconBuilder.useArrayPolymorphism,
classDiscriminator = hoconBuilder.classDiscriminator,
serializersModule = hoconBuilder.serializersModule
)
@@ -0,0 +1,140 @@
/*
* 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 kotlinx.serialization.*
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) {
if (serializer !is AbstractPolymorphicSerializer<*> || hocon.useArrayPolymorphism) {
serializer.serialize(this, value)
return
}

@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)
}

@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)
}

@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()
}
@@ -0,0 +1,22 @@
/*
* 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 kotlinx.serialization.*

internal fun SerializerNotFoundException(type: String?) = SerializationException(
"Polymorphic serializer was not found for " +
if (type == null) "missing class discriminator ('null')" else "class discriminator '$type'"
)

internal inline fun <reified T> ConfigValueTypeCastException(valueOrigin: ConfigOrigin) = SerializationException(
"${valueOrigin.description()} required to be of type ${T::class.simpleName}."
)

internal fun InvalidKeyKindException(value: ConfigValue) = SerializationException(
"Value of type '${value.valueType()}' can't be used in HOCON as a key in the map. " +
"It should have either primitive or enum kind."
)

0 comments on commit 261490a

Please sign in to comment.