From 261490a0ed61c6d8d833d00470c734290fb59014 Mon Sep 17 00:00:00 2001 From: Osip Fatkullin Date: Thu, 23 Dec 2021 17:39:44 +0300 Subject: [PATCH] Hocon encoder implementation (#1740) 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 --- .../hocon/api/kotlinx-serialization-hocon.api | 5 +- formats/hocon/build.gradle | 14 +- .../kotlinx/serialization/hocon/Hocon.kt | 98 +++++----- .../serialization/hocon/HoconEncoder.kt | 140 ++++++++++++++ .../serialization/hocon/HoconExceptions.kt | 22 +++ .../serialization/hocon/HoconSerialKind.kt | 20 ++ .../serialization/hocon/NamingConvention.kt | 13 ++ .../serialization/hocon/HoconEncoderTest.kt | 172 ++++++++++++++++++ .../hocon/HoconNamingConventionTest.kt | 42 ++++- .../hocon/HoconPolymorphismTest.kt | 126 ++++--------- .../hocon/HoconRootObjectsTest.kt | 26 ++- .../serialization/hocon/HoconTesting.kt | 24 +++ 12 files changed, 552 insertions(+), 150 deletions(-) create mode 100644 formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/HoconEncoder.kt create mode 100644 formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/HoconExceptions.kt create mode 100644 formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/HoconSerialKind.kt create mode 100644 formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/NamingConvention.kt create mode 100644 formats/hocon/src/test/kotlin/kotlinx/serialization/hocon/HoconEncoderTest.kt create mode 100644 formats/hocon/src/test/kotlin/kotlinx/serialization/hocon/HoconTesting.kt diff --git a/formats/hocon/api/kotlinx-serialization-hocon.api b/formats/hocon/api/kotlinx-serialization-hocon.api index 8e07e8571..a29292d08 100644 --- a/formats/hocon/api/kotlinx-serialization-hocon.api +++ b/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 (ZZLjava/lang/String;Lkotlinx/serialization/modules/SerializersModule;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (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; } @@ -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 diff --git a/formats/hocon/build.gradle b/formats/hocon/build.gradle index d79ea83ac..ab0a0fb86 100644 --- a/formats/hocon/build.gradle +++ b/formats/hocon/build.gradle @@ -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 } diff --git a/formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/Hocon.kt b/formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/Hocon.kt index d8b0f4cd9..e87283528 100644 --- a/formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/Hocon.kt +++ b/formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/Hocon.kt @@ -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 decodeFromConfig(deserializer: DeserializationStrategy, 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 encodeToConfig(serializer: SerializationStrategy, 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 : TaggedDecoder() { override val serializersModule: SerializersModule get() = this@Hocon.serializersModule @@ -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(configOrigin) } } @@ -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 @@ -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).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)) @@ -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 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 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)) } /** @@ -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. */ @@ -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 +) diff --git a/formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/HoconEncoder.kt b/formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/HoconEncoder.kt new file mode 100644 index 000000000..e75331984 --- /dev/null +++ b/formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/HoconEncoder.kt @@ -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 encodeSerializableValue(serializer: SerializationStrategy, value: T) { + if (serializer !is AbstractPolymorphicSerializer<*> || hocon.useArrayPolymorphism) { + serializer.serialize(this, value) + return + } + + @Suppress("UNCHECKED_CAST") + val casted = serializer as AbstractPolymorphicSerializer + 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() + + 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() + + 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() + + 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() +} diff --git a/formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/HoconExceptions.kt b/formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/HoconExceptions.kt new file mode 100644 index 000000000..52e711a15 --- /dev/null +++ b/formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/HoconExceptions.kt @@ -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 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." +) diff --git a/formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/HoconSerialKind.kt b/formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/HoconSerialKind.kt new file mode 100644 index 000000000..c20d7de55 --- /dev/null +++ b/formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/HoconSerialKind.kt @@ -0,0 +1,20 @@ +package kotlinx.serialization.hocon + +import kotlinx.serialization.* +import kotlinx.serialization.descriptors.* + +@OptIn(ExperimentalSerializationApi::class) +internal fun SerialDescriptor.hoconKind(useArrayPolymorphism: Boolean): SerialKind = when (kind) { + is PolymorphicKind -> { + if (useArrayPolymorphism) StructureKind.LIST else StructureKind.MAP + } + else -> kind +} + +@OptIn(ExperimentalSerializationApi::class) +internal val SerialKind.listLike + get() = this == StructureKind.LIST || this is PolymorphicKind + +@OptIn(ExperimentalSerializationApi::class) +internal val SerialKind.objLike + get() = this == StructureKind.CLASS || this == StructureKind.OBJECT diff --git a/formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/NamingConvention.kt b/formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/NamingConvention.kt new file mode 100644 index 000000000..4071bc7bc --- /dev/null +++ b/formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/NamingConvention.kt @@ -0,0 +1,13 @@ +package kotlinx.serialization.hocon + +import kotlinx.serialization.* +import kotlinx.serialization.descriptors.* + +private val NAMING_CONVENTION_REGEX by lazy { "[A-Z]".toRegex() } + +@OptIn(ExperimentalSerializationApi::class) +internal fun SerialDescriptor.getConventionElementName(index: Int, useConfigNamingConvention: Boolean): String { + val originalName = getElementName(index) + return if (!useConfigNamingConvention) originalName + else originalName.replace(NAMING_CONVENTION_REGEX) { "-${it.value.lowercase()}" } +} diff --git a/formats/hocon/src/test/kotlin/kotlinx/serialization/hocon/HoconEncoderTest.kt b/formats/hocon/src/test/kotlin/kotlinx/serialization/hocon/HoconEncoderTest.kt new file mode 100644 index 000000000..1462af7ea --- /dev/null +++ b/formats/hocon/src/test/kotlin/kotlinx/serialization/hocon/HoconEncoderTest.kt @@ -0,0 +1,172 @@ +package kotlinx.serialization.hocon + +import kotlinx.serialization.* +import org.junit.* +import kotlin.test.* + +class HoconEncoderTest { + + @Serializable + data class SimpleConfig(val value: Int) + + @Serializable + data class PrimitivesConfig( + val b: Boolean, + val i: Int, + val d: Double, + val c: Char, + val s: String, + val n: String?, + ) + + @Test + fun testEncodeSimpleConfig() { + val obj = PrimitivesConfig(b = true, i = 42, d = 32.2, c = 'x', s = "string", n = null) + val config = Hocon.encodeToConfig(obj) + + config.assertContains("b = true, i = 42, d = 32.2, c = x, s = string, n = null") + } + + @Serializable + data class ConfigWithEnum(val e: RegularEnum) + + @Serializable + enum class RegularEnum { VALUE } + + @Test + fun testEncodeConfigWithEnum() { + val obj = ConfigWithEnum(RegularEnum.VALUE) + val config = Hocon.encodeToConfig(obj) + + config.assertContains("e = VALUE") + } + + @Serializable + class ConfigWithIterables( + val array: BooleanArray, + val set: Set, + val list: List, + val listNullable: List?>, + ) + + @Test + fun testEncodeConfigWithIterables() { + val obj = ConfigWithIterables( + array = booleanArrayOf(true, false), + set = setOf(3, 1, 4), + list = listOf("A", "B"), + listNullable = listOf(null, setOf(SimpleConfig(42), null)), + ) + val config = Hocon.encodeToConfig(obj) + + config.assertContains( + """ + array = [true, false] + set = [3, 1, 4] + list = [A, B] + listNullable = [null, [{ value: 42 }, null]] + """ + ) + } + + @Serializable + data class ConfigWithNested( + val nested: SimpleConfig, + val nestedList: List, + ) + + @Test + fun testNestedConfigEncoding() { + val obj = ConfigWithNested( + nested = SimpleConfig(1), + nestedList = listOf(SimpleConfig(2)), + ) + val config = Hocon.encodeToConfig(obj) + + config.assertContains("nested { value = 1 }, nestedList = [{ value: 2 }]") + } + + @Test + fun testMapEncoding() { + val objMap = mapOf( + "one" to SimpleConfig(1), + "two" to SimpleConfig(2), + "three" to null, + null to SimpleConfig(0), + ) + val config = Hocon.encodeToConfig(objMap) + + config.assertContains( + """ + one { value = 1 } + two { value = 2 } + three: null + null { value = 0 } + """ + ) + } + + @Serializable + data class ConfigWithDefaults( + val defInt: Int = 0, + val defString: String = "", + ) + + @Test + fun testDefaultsNotEncodedByDefault() { + val obj = ConfigWithDefaults(defInt = 42) + val config = Hocon.encodeToConfig(obj) + + config.assertContains("defInt = 42") + } + + @Test + fun testDefaultsEncodedIfEnabled() { + val hocon = Hocon { encodeDefaults = true } + val obj = ConfigWithDefaults(defInt = 42) + val config = hocon.encodeToConfig(obj) + + config.assertContains("defInt = 42, defString = \"\"") + } + + @Serializable + data class PrimitiveKeysMaps( + val number: Map, + val boolean: Map, + val nullable: Map, + val enum: Map, + ) + + @Test + fun testPrimitiveMapKeysEncoding() { + val obj = PrimitiveKeysMaps( + number = mapOf(42 to "these"), + boolean = mapOf(true to "keys"), + nullable = mapOf(null to "are"), + enum = mapOf(RegularEnum.VALUE to "strings"), + ) + val config = Hocon.encodeToConfig(obj) + + config.assertContains( + """ + number { "42" = these } + boolean { "true" = keys } + nullable { "null" = are } + enum { "VALUE" = strings } + """ + ) + } + + @Test + fun testEncodeMapWithUnsupportedKeys() { + assertWrongMapKey("LIST", listOf(1, 1, 2, 3, 5)) + assertWrongMapKey("OBJECT", mapOf(1 to "one", 2 to "two")) + } + + private fun assertWrongMapKey(type: String, key: Any?) { + val message = "Value of type '$type' can't be used in HOCON as a key in the map. " + + "It should have either primitive or enum kind." + val obj = mapOf(key to "value") + assertFailsWith(message) { Hocon.encodeToConfig(obj) } + } +} diff --git a/formats/hocon/src/test/kotlin/kotlinx/serialization/hocon/HoconNamingConventionTest.kt b/formats/hocon/src/test/kotlin/kotlinx/serialization/hocon/HoconNamingConventionTest.kt index b07687776..889abcd07 100644 --- a/formats/hocon/src/test/kotlin/kotlinx/serialization/hocon/HoconNamingConventionTest.kt +++ b/formats/hocon/src/test/kotlin/kotlinx/serialization/hocon/HoconNamingConventionTest.kt @@ -19,21 +19,41 @@ class HoconNamingConventionTest { @Serializable data class CaseWithInnerConfig(val caseConfig: CaseConfig, val serialNameConfig: SerialNameConfig) + private val hocon = Hocon { + useConfigNamingConvention = true + } + @Test - fun `deserialize using naming convention`() { + fun testDeserializeUsingNamingConvention() { val obj = deserializeConfig("a-char-value = t, a-string-value = test", CaseConfig.serializer(), true) assertEquals('t', obj.aCharValue) assertEquals("test", obj.aStringValue) } @Test - fun `use serial name instead of naming convention if provided`() { + fun testSerializeUsingNamingConvention() { + val obj = CaseConfig(aCharValue = 't', aStringValue = "test") + val config = hocon.encodeToConfig(obj) + + config.assertContains("a-char-value = t, a-string-value = test") + } + + @Test + fun testDeserializeUsingSerialNameInsteadOfNamingConvention() { val obj = deserializeConfig("an-id-value = 42", SerialNameConfig.serializer(), true) assertEquals(42, obj.anIDValue) } @Test - fun `deserialize inner values using naming convention`() { + fun testSerializeUsingSerialNameInsteadOfNamingConvention() { + val obj = SerialNameConfig(anIDValue = 42) + val config = hocon.encodeToConfig(obj) + + config.assertContains("an-id-value = 42") + } + + @Test + fun testDeserializeInnerValuesUsingNamingConvention() { val configString = "case-config {a-char-value = b, a-string-value = bar}, serial-name-config {an-id-value = 21}" val obj = deserializeConfig(configString, CaseWithInnerConfig.serializer(), true) with(obj.caseConfig) { @@ -42,4 +62,20 @@ class HoconNamingConventionTest { } assertEquals(21, obj.serialNameConfig.anIDValue) } + + @Test + fun testSerializeInnerValuesUsingNamingConvention() { + val obj = CaseWithInnerConfig( + caseConfig = CaseConfig(aCharValue = 't', aStringValue = "test"), + serialNameConfig = SerialNameConfig(anIDValue = 42) + ) + val config = hocon.encodeToConfig(obj) + + config.assertContains( + """ + case-config { a-char-value = t, a-string-value = test } + serial-name-config { an-id-value = 42 } + """ + ) + } } diff --git a/formats/hocon/src/test/kotlin/kotlinx/serialization/hocon/HoconPolymorphismTest.kt b/formats/hocon/src/test/kotlin/kotlinx/serialization/hocon/HoconPolymorphismTest.kt index 40de05afd..db038e70b 100644 --- a/formats/hocon/src/test/kotlin/kotlinx/serialization/hocon/HoconPolymorphismTest.kt +++ b/formats/hocon/src/test/kotlin/kotlinx/serialization/hocon/HoconPolymorphismTest.kt @@ -1,9 +1,7 @@ package kotlinx.serialization.hocon -import com.typesafe.config.ConfigFactory import kotlinx.serialization.* -import org.junit.Assert.* -import org.junit.Test +import org.junit.* class HoconPolymorphismTest { @Serializable @@ -40,114 +38,68 @@ class HoconPolymorphismTest { @Test fun testArrayDataClass() { - val config = ConfigFactory.parseString( - """{ - sealed: [ - "data_class" - {name="testArrayDataClass" - intField=10} - ] - }""") - val root = arrayHocon.decodeFromConfig(CompositeClass.serializer(), config) - val sealed = root.sealed - - assertTrue(sealed is Sealed.DataClassChild) - sealed as Sealed.DataClassChild - assertEquals("testArrayDataClass", sealed.name) - assertEquals(10, sealed.intField) + arrayHocon.assertStringFormAndRestored( + expected = "sealed: [ data_class, { name = testDataClass, intField = 1 } ]", + original = CompositeClass(Sealed.DataClassChild("testDataClass")), + serializer = CompositeClass.serializer(), + ) } @Test fun testArrayObject() { - val config = ConfigFactory.parseString( - """{ - sealed: [ - "object" - {} - ] - }""") - val root = arrayHocon.decodeFromConfig(CompositeClass.serializer(), config) - val sealed = root.sealed - - assertSame(Sealed.ObjectChild, sealed) + arrayHocon.assertStringFormAndRestored( + expected = "sealed: [ object, {} ]", + original = CompositeClass(Sealed.ObjectChild), + serializer = CompositeClass.serializer(), + ) } @Test fun testObject() { - val config = ConfigFactory.parseString("""{type="object"}""") - val sealed = objectHocon.decodeFromConfig(Sealed.serializer(), config) - - assertSame(Sealed.ObjectChild, sealed) + objectHocon.assertStringFormAndRestored( + expected = "type = object", + original = Sealed.ObjectChild, + serializer = Sealed.serializer(), + ) } @Test fun testNestedDataClass() { - val config = ConfigFactory.parseString( - """{ - sealed: { - type="data_class" - name="test name" - intField=10 - } - }""") - val root = objectHocon.decodeFromConfig(CompositeClass.serializer(), config) - val sealed = root.sealed - - assertTrue(sealed is Sealed.DataClassChild) - sealed as Sealed.DataClassChild - assertEquals("test name", sealed.name) - assertEquals(10, sealed.intField) + objectHocon.assertStringFormAndRestored( + expected = "sealed { type = data_class, name = testDataClass, intField = 1 }", + original = CompositeClass(Sealed.DataClassChild("testDataClass")), + serializer = CompositeClass.serializer(), + ) } @Test - fun testDataClass() { - val config = ConfigFactory.parseString( - """{ - type="data_class" - name="testDataClass" - intField=10 - }""") - val sealed = objectHocon.decodeFromConfig(Sealed.serializer(), config) - - assertTrue(sealed is Sealed.DataClassChild) - sealed as Sealed.DataClassChild - assertEquals("testDataClass", sealed.name) - assertEquals(10, sealed.intField) + fun testDataClassDecode() { + objectHocon.assertStringFormAndRestored( + expected = "type = data_class, name = testDataClass, intField = 1", + original = Sealed.DataClassChild("testDataClass"), + serializer = Sealed.serializer(), + ) } @Test - fun testChangeDiscriminator() { + fun testChangedDiscriminator() { val hocon = Hocon(objectHocon) { classDiscriminator = "key" } - val config = ConfigFactory.parseString( - """{ - type="override" - key="type_child" - intField=11 - }""") - val sealed = hocon.decodeFromConfig(Sealed.serializer(), config) - - assertTrue(sealed is Sealed.TypeChild) - sealed as Sealed.TypeChild - assertEquals("override", sealed.type) - assertEquals(11, sealed.intField) + hocon.assertStringFormAndRestored( + expected = "type = override, key = type_child, intField = 2", + original = Sealed.TypeChild(type = "override"), + serializer = Sealed.serializer(), + ) } @Test - fun testChangeTypePropertyName() { - val config = ConfigFactory.parseString( - """{ - my_type="override" - type="annotated_type_child" - intField=12 - }""") - val sealed = objectHocon.decodeFromConfig(Sealed.serializer(), config) - - assertTrue(sealed is Sealed.AnnotatedTypeChild) - sealed as Sealed.AnnotatedTypeChild - assertEquals("override", sealed.type) - assertEquals(12, sealed.intField) + fun testChangedTypePropertyName() { + objectHocon.assertStringFormAndRestored( + expected = "type = annotated_type_child, my_type = override, intField = 3", + original = Sealed.AnnotatedTypeChild(type = "override"), + serializer = Sealed.serializer(), + ) } } diff --git a/formats/hocon/src/test/kotlin/kotlinx/serialization/hocon/HoconRootObjectsTest.kt b/formats/hocon/src/test/kotlin/kotlinx/serialization/hocon/HoconRootObjectsTest.kt index f4d87cf98..ebdf3d61a 100644 --- a/formats/hocon/src/test/kotlin/kotlinx/serialization/hocon/HoconRootObjectsTest.kt +++ b/formats/hocon/src/test/kotlin/kotlinx/serialization/hocon/HoconRootObjectsTest.kt @@ -4,10 +4,9 @@ package kotlinx.serialization.hocon -import com.typesafe.config.ConfigFactory -import kotlinx.serialization.Serializable -import org.junit.Ignore -import org.junit.Test +import com.typesafe.config.* +import kotlinx.serialization.* +import org.junit.* import kotlin.test.* class HoconRootMapTest { @@ -33,8 +32,8 @@ class HoconRootMapTest { @Serializable data class CompositeValue( - val a: String, - val b: Int + val a: String, + val b: Int ) @Test @@ -61,6 +60,21 @@ class HoconRootMapTest { assertNull(Hocon.decodeFromConfig?>(config)) } + @Test + fun testUnsupportedRootObjectsEncode() { + assertWrongRootValue("LIST", listOf(1, 1, 2, 3, 5)) + assertWrongRootValue("NUMBER", 42) + assertWrongRootValue("BOOLEAN", false) + assertWrongRootValue("NULL", null) + assertWrongRootValue("STRING", "string") + } + + private fun assertWrongRootValue(type: String, rootValue: Any?) { + val message = "Value of type '$type' can't be used at the root of HOCON Config. " + + "It should be either object or map." + assertFailsWith(message) { Hocon.encodeToConfig(rootValue) } + } + @Ignore @Test fun testErrors() { diff --git a/formats/hocon/src/test/kotlin/kotlinx/serialization/hocon/HoconTesting.kt b/formats/hocon/src/test/kotlin/kotlinx/serialization/hocon/HoconTesting.kt new file mode 100644 index 000000000..4f54b7083 --- /dev/null +++ b/formats/hocon/src/test/kotlin/kotlinx/serialization/hocon/HoconTesting.kt @@ -0,0 +1,24 @@ +package kotlinx.serialization.hocon + +import com.typesafe.config.* +import kotlinx.serialization.* +import org.junit.Assert.assertEquals + +internal inline fun Hocon.assertStringFormAndRestored( + expected: String, + original: T, + serializer: KSerializer, + printResult: Boolean = false, +) { + val expectedConfig = ConfigFactory.parseString(expected) + val config = this.encodeToConfig(serializer, original) + if (printResult) println("[Serialized form] $config") + assertEquals(expectedConfig, config) + val restored = this.decodeFromConfig(serializer, config) + if (printResult) println("[Restored form] $restored") + assertEquals(original, restored) +} + +internal fun Config.assertContains(expected: String) { + assertEquals(ConfigFactory.parseString(expected), this) +}