From f51b0c60b7d966a9afd1075279a6cba68a759413 Mon Sep 17 00:00:00 2001 From: Alexander Mikhailov <33699084+alexmihailov@users.noreply.github.com> Date: Thu, 27 Oct 2022 18:51:51 +0300 Subject: [PATCH] Add support for deserialization Duration in HOCON format (#2073) Currently **kotlin.time.Duration** by default supports deserialization from **ISO-8601-2** format. But **HOCON** uses a different [format](https://github.com/lightbend/config/blob/main/HOCON.md#duration-format). Made changes so that when deserializing **kotlin.time.Duration** from **HOCON**, by default, use the format specified in the documentation. --- build.gradle | 11 +- .../kotlinx/serialization/hocon/Hocon.kt | 55 +++++++-- .../hocon/internal/SuppressAnimalSniffer.kt | 10 ++ .../src/mainModule/kotlin/module-info.java | 1 + .../hocon/HoconDurationDeserializerTest.kt | 114 ++++++++++++++++++ 5 files changed, 178 insertions(+), 13 deletions(-) create mode 100644 formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/internal/SuppressAnimalSniffer.kt create mode 100644 formats/hocon/src/test/kotlin/kotlinx/serialization/hocon/HoconDurationDeserializerTest.kt diff --git a/build.gradle b/build.gradle index 1be2e0168..170a9df9a 100644 --- a/build.gradle +++ b/build.gradle @@ -175,7 +175,16 @@ subprojects { afterEvaluate { // Can be applied only when the project is evaluated animalsniffer { sourceSets = [sourceSets.main] - annotation = (name == "kotlinx-serialization-core")? "kotlinx.serialization.internal.SuppressAnimalSniffer" : "kotlinx.serialization.json.internal.SuppressAnimalSniffer" + def annotationValue = "kotlinx.serialization.json.internal.SuppressAnimalSniffer" + switch (name) { + case "kotlinx-serialization-core": + annotationValue = "kotlinx.serialization.internal.SuppressAnimalSniffer" + break + case "kotlinx-serialization-hocon": + annotationValue = "kotlinx.serialization.hocon.internal.SuppressAnimalSniffer" + break + } + annotation = annotationValue } dependencies { signature 'net.sf.androidscents.signature:android-api-level-14:4.0_r4@signature' 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 54b2ca56c..19ce09c78 100644 --- a/formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/Hocon.kt +++ b/formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/Hocon.kt @@ -5,10 +5,13 @@ 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.internal.* import kotlinx.serialization.modules.* @@ -19,6 +22,10 @@ import kotlinx.serialization.modules.* * [Config] object represents "Human-Optimized Config Object Notation" — * [HOCON][https://github.com/lightbend/config#using-hocon-the-json-superset]. * + * [Duration] objects are decoded using "HOCON duration format" - + * [Duration format][https://github.com/lightbend/config/blob/main/HOCON.md#duration-format] + * [Duration] objects encoding does not currently support duration HOCON format and uses standard Duration serializer which produces ISO-8601-2 string. + * * @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. @@ -86,6 +93,18 @@ public sealed class Hocon( private fun getTaggedNumber(tag: T) = validateAndCast(tag) + @SuppressAnimalSniffer + protected fun decodeDurationInHoconFormat(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 + } + override fun decodeTaggedString(tag: T) = validateAndCast(tag) override fun decodeTaggedBoolean(tag: T) = validateAndCast(tag) @@ -138,19 +157,21 @@ public sealed class Hocon( } override fun decodeSerializableValue(deserializer: DeserializationStrategy): T { - if (deserializer !is AbstractPolymorphicSerializer<*> || useArrayPolymorphism) { - return deserializer.deserialize(this) + return when { + deserializer.descriptor == Duration.serializer().descriptor -> decodeDurationInHoconFormat(currentTag) + deserializer !is AbstractPolymorphicSerializer<*> || useArrayPolymorphism -> deserializer.deserialize(this) + else -> { + val config = if (currentTagOrNull != null) conf.getConfig(currentTag) else conf + + val reader = ConfigReader(config) + val type = reader.decodeTaggedString(classDiscriminator) + val actualSerializer = deserializer.findPolymorphicSerializerOrNull(reader, type) + ?: throw SerializerNotFoundException(type) + + @Suppress("UNCHECKED_CAST") + (actualSerializer as DeserializationStrategy).deserialize(reader) + } } - - val config = if (currentTagOrNull != null) conf.getConfig(currentTag) else conf - - val reader = ConfigReader(config) - val type = reader.decodeTaggedString(classDiscriminator) - val actualSerializer = deserializer.findPolymorphicSerializerOrNull(reader, type) - ?: throw SerializerNotFoundException(type) - - @Suppress("UNCHECKED_CAST") - return (actualSerializer as DeserializationStrategy).deserialize(reader) } override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder { @@ -174,6 +195,11 @@ public sealed class Hocon( private inner class ListConfigReader(private val list: ConfigList) : ConfigConverter() { private var ind = -1 + override fun decodeSerializableValue(deserializer: DeserializationStrategy): T = when (deserializer.descriptor) { + Duration.serializer().descriptor -> decodeDurationInHoconFormat(ind) + else -> super.decodeSerializableValue(deserializer) + } + override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder = when { descriptor.kind.listLike -> ListConfigReader(list[currentTag] as ConfigList) @@ -209,6 +235,11 @@ public sealed class Hocon( private val indexSize = values.size * 2 + override fun decodeSerializableValue(deserializer: DeserializationStrategy): T = when (deserializer.descriptor) { + Duration.serializer().descriptor -> decodeDurationInHoconFormat(ind) + else -> super.decodeSerializableValue(deserializer) + } + override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder = when { descriptor.kind.listLike -> ListConfigReader(values[currentTag / 2] as ConfigList) diff --git a/formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/internal/SuppressAnimalSniffer.kt b/formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/internal/SuppressAnimalSniffer.kt new file mode 100644 index 000000000..fa348bb6a --- /dev/null +++ b/formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/internal/SuppressAnimalSniffer.kt @@ -0,0 +1,10 @@ +package kotlinx.serialization.hocon.internal + +/** + * Suppresses Animal Sniffer plugin errors for certain methods. + * Such methods include references to Java 8 methods that are not + * available in Android API, but can be desugared by R8. + */ +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +internal annotation class SuppressAnimalSniffer diff --git a/formats/hocon/src/mainModule/kotlin/module-info.java b/formats/hocon/src/mainModule/kotlin/module-info.java index b828065ca..a21583cc9 100644 --- a/formats/hocon/src/mainModule/kotlin/module-info.java +++ b/formats/hocon/src/mainModule/kotlin/module-info.java @@ -1,6 +1,7 @@ module kotlinx.serialization.hocon { requires transitive kotlin.stdlib; requires transitive kotlinx.serialization.core; + requires transitive kotlin.stdlib.jdk8; requires transitive typesafe.config; exports kotlinx.serialization.hocon; diff --git a/formats/hocon/src/test/kotlin/kotlinx/serialization/hocon/HoconDurationDeserializerTest.kt b/formats/hocon/src/test/kotlin/kotlinx/serialization/hocon/HoconDurationDeserializerTest.kt new file mode 100644 index 000000000..789b7f43a --- /dev/null +++ b/formats/hocon/src/test/kotlin/kotlinx/serialization/hocon/HoconDurationDeserializerTest.kt @@ -0,0 +1,114 @@ +package kotlinx.serialization.hocon + +import kotlin.test.* +import kotlin.time.* +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.Duration.Companion.seconds +import kotlinx.serialization.* +import org.junit.Assert.* +import org.junit.Test + +class HoconDurationDeserializerTest { + + @Serializable + data class Simple(val d: Duration) + + @Serializable + data class Nullable(val d: Duration?) + + @Serializable + data class ConfigList(val ld: List) + + @Serializable + data class ConfigMap(val mp: Map) + + @Serializable + data class ConfigMapDurationKey(val mp: Map) + + @Serializable + data class Complex( + val i: Int, + val s: Simple, + val n: Nullable, + val l: List, + val ln: List, + val f: Boolean, + val ld: List, + val mp: Map, + val mpp: Map + ) + + @Test + fun testDeserializeDurationInHoconFormat() { + var obj = deserializeConfig("d = 10s", Simple.serializer()) + assertEquals(10.seconds, obj.d) + obj = deserializeConfig("d = 10 hours", Simple.serializer()) + assertEquals(10.hours, obj.d) + obj = deserializeConfig("d = 5 ms", Simple.serializer()) + assertEquals(5.milliseconds, obj.d) + } + + @Test + fun testDeserializeNullableDurationInHoconFormat() { + var obj = deserializeConfig("d = null", Nullable.serializer()) + assertNull(obj.d) + + obj = deserializeConfig("d = 5 days", Nullable.serializer()) + assertEquals(5.days, obj.d!!) + } + + @Test + fun testDeserializeListOfDurationInHoconFormat() { + val obj = deserializeConfig("ld: [ 1d, 1m, 5ns ]", ConfigList.serializer()) + assertEquals(listOf(1.days, 1.minutes, 5.nanoseconds), obj.ld) + } + + @Test + fun testDeserializeMapOfDurationInHoconFormat() { + val obj = deserializeConfig(""" + mp: { day = 2d, hour = 5 hours, minute = 3 minutes } + """.trimIndent(), ConfigMap.serializer()) + assertEquals(mapOf("day" to 2.days, "hour" to 5.hours, "minute" to 3.minutes), obj.mp) + + val objDurationKey = deserializeConfig(""" + mp: { 1 hour = 3600s } + """.trimIndent(), ConfigMapDurationKey.serializer()) + assertEquals(mapOf(1.hours to 3600.seconds), objDurationKey.mp) + } + + @Test + fun testDeserializeComplexDurationInHoconFormat() { + val obj = deserializeConfig(""" + i = 6 + s: { d = 5m } + n: { d = null } + l: [ { d = 1m }, { d = 2s } ] + ln: [ { d = null }, { d = 6h } ] + f = true + ld: [ 1d, 1m, 5ns ] + mp: { day = 2d, hour = 5 hours, minute = 3 minutes } + mpp: { 1 hour = 3600s } + """.trimIndent(), Complex.serializer()) + assertEquals(5.minutes, obj.s.d) + assertNull(obj.n.d) + assertEquals(listOf(Simple(1.minutes), Simple(2.seconds)), obj.l) + assertEquals(listOf(Nullable(null), Nullable(6.hours)), obj.ln) + assertEquals(6, obj.i) + assertTrue(obj.f) + assertEquals(listOf(1.days, 1.minutes, 5.nanoseconds), obj.ld) + assertEquals(mapOf("day" to 2.days, "hour" to 5.hours, "minute" to 3.minutes), obj.mp) + assertEquals(mapOf(1.hours to 3600.seconds), obj.mpp) + } + + @Test + fun testThrowsWhenNotTimeUnitHocon() { + val message = "Value at d cannot be read as kotlin.Duration because it is not a valid HOCON duration value" + assertFailsWith(message) { + deserializeConfig("d = 10 unknown", Simple.serializer()) + } + } +}