Repository: config4k/config4k (kotlin)

Issue:
`java.time.temporal.TemporalAmount` should be serializable
Inspired by a great work of @alexmihailov in https://github.com/Kotlin/kotlinx.serialization/pull/2133

Pull request:
Fixed #289

Patch:
diff --git a/api/config4k.api b/api/config4k.api
index d6d88bb..e09bd28 100644
--- a/api/config4k.api
+++ b/api/config4k.api
@@ -83,3 +83,12 @@ public final class io/github/config4k/serializers/PeriodSerializer : kotlinx/ser
 	public fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/time/Period;)V
 }
 
+public final class io/github/config4k/serializers/TemporalAmountSerializer : kotlinx/serialization/KSerializer {
+	public static final field INSTANCE Lio/github/config4k/serializers/TemporalAmountSerializer;
+	public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object;
+	public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/time/temporal/TemporalAmount;
+	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/temporal/TemporalAmount;)V
+}
+
diff --git a/src/main/kotlin/io/github/config4k/Config4k.kt b/src/main/kotlin/io/github/config4k/Config4k.kt
index b6e4118..015c8c8 100644
--- a/src/main/kotlin/io/github/config4k/Config4k.kt
+++ b/src/main/kotlin/io/github/config4k/Config4k.kt
@@ -3,16 +3,20 @@ package io.github.config4k
 import io.github.config4k.serializers.ConfigSerializer
 import io.github.config4k.serializers.ConfigValueSerializer
 import io.github.config4k.serializers.PeriodSerializer
+import io.github.config4k.serializers.TemporalAmountSerializer
 import kotlinx.serialization.hocon.Hocon
+import kotlinx.serialization.hocon.serializers.JavaDurationSerializer
 import kotlinx.serialization.modules.SerializersModule
 import kotlinx.serialization.modules.contextual
 import kotlinx.serialization.modules.plus
 
 public val Config4kModule: SerializersModule =
     SerializersModule {
+        contextual(JavaDurationSerializer)
+        contextual(PeriodSerializer)
+        contextual(TemporalAmountSerializer)
         contextual(ConfigSerializer)
         contextual(ConfigValueSerializer)
-        contextual(PeriodSerializer)
     }
 
 public val Config4k: Hocon =
diff --git a/src/main/kotlin/io/github/config4k/serializers/PeriodSerializer.kt b/src/main/kotlin/io/github/config4k/serializers/PeriodSerializer.kt
index e86af83..a571ee3 100644
--- a/src/main/kotlin/io/github/config4k/serializers/PeriodSerializer.kt
+++ b/src/main/kotlin/io/github/config4k/serializers/PeriodSerializer.kt
@@ -61,7 +61,7 @@ public object PeriodSerializer : KSerializer<Period> {
                     if (it.years == 0 && it.months == 0) {
                         "${it.days} d"
                     } else if (it.days == 0) {
-                        if (it.months == 0) "${it.years} y" else "${it.toTotalMonths()} m"
+                        if (it.months == 0) "${it.years} y" else "${it.toTotalMonths()} mo"
                     } else {
                         throw SerializationException("java.time.Period can be specified by only one time unit")
                     }
diff --git a/src/main/kotlin/io/github/config4k/serializers/TemporalAmountSerializer.kt b/src/main/kotlin/io/github/config4k/serializers/TemporalAmountSerializer.kt
new file mode 100644
index 0000000..a3616e6
--- /dev/null
+++ b/src/main/kotlin/io/github/config4k/serializers/TemporalAmountSerializer.kt
@@ -0,0 +1,63 @@
+package io.github.config4k.serializers
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import kotlinx.serialization.hocon.HoconDecoder
+import kotlinx.serialization.hocon.HoconEncoder
+import kotlinx.serialization.hocon.serializers.JavaDurationSerializer
+import java.time.Duration
+import java.time.Period
+import java.time.temporal.TemporalAmount
+
+/**
+ * Serializer for [TemporalAmount].
+ *
+ * Decoding uses [TemporalAmount format](https://github.com/lightbend/config/blob/main/HOCON.md#period-format).
+ * This method will first try to get the value as a [Duration], and if unsuccessful, then as a [Period].
+ * This means that values like "5m" will be parsed as 5 minutes rather than 5 months
+ *
+ * Encoding is available for two implementations: [Duration] and [Period].
+ *
+ * Usage example:
+ * ```
+ * @Serializable
+ * data class ExampleTemporalAmount(
+ *   @Serializable(TemporalAmountSerializer::class)
+ *   val temporal: TemporalAmount,
+ * )
+ * val config = ConfigFactory.parseString("temporal = 1 y")
+ * val exampleTemporalAmount: ExampleTemporalAmount = Hocon.decodeFromConfig(config)
+ * val newConfig = Hocon.encodeToConfig(ExampleTemporalAmount.serializer(), exampleTemporalAmount)
+ * ```
+ */
+public object TemporalAmountSerializer : KSerializer<TemporalAmount> {
+    override val descriptor: SerialDescriptor =
+        PrimitiveSerialDescriptor("hocon.java.time.temporal.TemporalAmount", PrimitiveKind.STRING)
+
+    override fun deserialize(decoder: Decoder): TemporalAmount =
+        if (decoder is HoconDecoder) {
+            decoder.decodeConfigValue { conf, path -> conf.getTemporal(path) }
+        } else {
+            throw SerializationException("This class can be decoded only by Hocon format")
+        }
+
+    override fun serialize(
+        encoder: Encoder,
+        value: TemporalAmount,
+    ) {
+        if (encoder is HoconEncoder) {
+            when (value) {
+                is Duration -> JavaDurationSerializer.serialize(encoder, value)
+                is Period -> PeriodSerializer.serialize(encoder, value)
+                else -> throw SerializationException("Class ${value::class.java.name} can't be encoded by Hocon format")
+            }
+        } else {
+            throw SerializationException("This class can be encoded only by Hocon format")
+        }
+    }
+}


Test patch:
diff --git a/src/test/kotlin/io/github/config4k/serializers/PeriodSerializerTest.kt b/src/test/kotlin/io/github/config4k/serializers/PeriodSerializerTest.kt
index 1121de6..e934243 100644
--- a/src/test/kotlin/io/github/config4k/serializers/PeriodSerializerTest.kt
+++ b/src/test/kotlin/io/github/config4k/serializers/PeriodSerializerTest.kt
@@ -19,10 +19,12 @@ import org.junit.jupiter.api.Test
 import org.junit.jupiter.api.assertThrows
 import java.time.Period
 
-val OneYear: Period = Period.ofYears(1)
-val TenDays: Period = Period.ofDays(10)
-
 class PeriodSerializerTest {
+    private companion object {
+        val OneYear: Period = Period.ofYears(1)
+        val TenDays: Period = Period.ofDays(10)
+    }
+
     @Nested
     inner class SimpleTest {
         @Test
@@ -80,11 +82,11 @@ class PeriodSerializerTest {
             var test = Config4k.encodeToConfig(Conf(OneYear))
             assertThat(test.render()).isEqualTo("p=\"1 y\"")
             test = Config4k.encodeToConfig(Conf(Period.ofMonths(10)))
-            assertThat(test.render()).isEqualTo("p=\"10 m\"")
+            assertThat(test.render()).isEqualTo("p=\"10 mo\"")
             test = Config4k.encodeToConfig(Conf(TenDays))
             assertThat(test.render()).isEqualTo("p=\"10 d\"")
             test = Config4k.encodeToConfig(Conf(Period.of(1, 20, 0)))
-            assertThat(test.render()).isEqualTo("p=\"32 m\"")
+            assertThat(test.render()).isEqualTo("p=\"32 mo\"")
 
             assertThrows<SerializationException> { Config4k.encodeToConfig(Conf(Period.of(1, 0, 2))) }
                 .shouldHaveMessage("java.time.Period can be specified by only one time unit")
diff --git a/src/test/kotlin/io/github/config4k/serializers/TemporalAmountSerializerTest.kt b/src/test/kotlin/io/github/config4k/serializers/TemporalAmountSerializerTest.kt
new file mode 100644
index 0000000..6e29e19
--- /dev/null
+++ b/src/test/kotlin/io/github/config4k/serializers/TemporalAmountSerializerTest.kt
@@ -0,0 +1,258 @@
+package io.github.config4k.serializers
+
+import com.typesafe.config.Config
+import io.github.config4k.Config4k
+import io.github.config4k.render
+import io.github.config4k.toConfig
+import io.mockk.mockk
+import kotlinx.serialization.Contextual
+import kotlinx.serialization.MissingFieldException
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.hocon.decodeFromConfig
+import kotlinx.serialization.hocon.encodeToConfig
+import org.assertj.core.api.Assertions.assertThat
+import org.assertj.core.api.Assertions.assertThatThrownBy
+import org.assertj.core.api.Assertions.catchThrowableOfType
+import org.junit.jupiter.api.Nested
+import org.junit.jupiter.api.Test
+import java.time.Duration
+import java.time.Period
+import java.time.chrono.JapaneseChronology
+import java.time.temporal.TemporalAmount
+
+class TemporalAmountSerializerTest {
+    private companion object {
+        val OneYear: TemporalAmount = Period.ofYears(1)
+        val FiveMinutes: Duration = Duration.ofMinutes(5)
+    }
+
+    @Nested
+    inner class SimpleTest {
+        @Test
+        fun checkDecoding() {
+            @Serializable
+            data class Conf(
+                @Contextual val t: TemporalAmount,
+            )
+
+            var test: Conf = Config4k.decodeFromConfig("t = 1 y".toConfig())
+            assertThat(test.t).isEqualTo(OneYear)
+
+            test = Config4k.decodeFromConfig("t = 5 m".toConfig())
+            assertThat(test.t).isEqualTo(FiveMinutes)
+
+            test = Config4k.decodeFromConfig("t = 5 mo".toConfig())
+            assertThat(test.t).isEqualTo(Period.ofMonths(5))
+
+            val thrown =
+                catchThrowableOfType(MissingFieldException::class.java) {
+                    Config4k.decodeFromConfig<Conf>("foo = bar".toConfig())
+                }
+            assertThat(thrown.missingFields).contains("t")
+        }
+
+        @Test
+        fun checkIncorrectDecoder() {
+            assertThatThrownBy { TemporalAmountSerializer.deserialize(mockk()) }
+                .isInstanceOf(SerializationException::class.java)
+                .hasMessage("This class can be decoded only by Hocon format")
+        }
+
+        @Test
+        fun checkNullableDecoding() {
+            @Serializable
+            data class Conf(
+                @Contextual val t: TemporalAmount? = null,
+            )
+
+            val test: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(test.t).isNull()
+        }
+
+        @Test
+        fun checkDefaultDecoding() {
+            @Serializable
+            data class Conf(
+                @Contextual val t: TemporalAmount = OneYear,
+            )
+
+            val test: Conf = Config4k.decodeFromConfig("t = 5 m".toConfig())
+            assertThat(test.t).isEqualTo(FiveMinutes)
+
+            val default: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(default.t).isEqualTo(OneYear)
+        }
+
+        @Test
+        fun checkEncoding() {
+            @Serializable
+            data class Conf(
+                @Contextual val t: TemporalAmount,
+            )
+
+            var test = Config4k.encodeToConfig(Conf(FiveMinutes))
+            assertThat(test.render()).isEqualTo("t=\"5 m\"")
+            test = Config4k.encodeToConfig(Conf(Period.ofMonths(10)))
+            assertThat(test.render()).isEqualTo("t=\"10 mo\"")
+            test = Config4k.encodeToConfig(Conf(OneYear))
+            assertThat(test.render()).isEqualTo("t=\"1 y\"")
+
+            assertThatThrownBy { Config4k.encodeToConfig(Conf(JapaneseChronology.INSTANCE.period(10, 12, 0))) }
+                .isInstanceOf(SerializationException::class.java)
+                .hasMessage("Class java.time.chrono.ChronoPeriodImpl can't be encoded by Hocon format")
+        }
+
+        @Test
+        fun checkIncorrectEncoder() {
+            assertThatThrownBy { TemporalAmountSerializer.serialize(mockk(), mockk()) }
+                .isInstanceOf(SerializationException::class.java)
+                .hasMessage("This class can be encoded only by Hocon format")
+        }
+    }
+
+    @Nested
+    inner class ListTest {
+        @Test
+        fun checkDecoding() {
+            @Serializable
+            data class Conf(
+                val ts: List<@Contextual TemporalAmount>,
+            )
+
+            val emptyTest: Conf = Config4k.decodeFromConfig("ts = []".toConfig())
+            assertThat(emptyTest.ts).isEmpty()
+
+            val test: Conf = Config4k.decodeFromConfig("ts = [1 y, 5 m]".toConfig())
+            assertThat(test.ts).containsExactly(OneYear, FiveMinutes)
+
+            val thrown =
+                catchThrowableOfType(MissingFieldException::class.java) { Config4k.decodeFromConfig<Conf>("foo = bar".toConfig()) }
+            assertThat(thrown.missingFields).contains("ts")
+        }
+
+        @Test
+        fun checkNullableDecoding() {
+            @Serializable
+            data class Conf(
+                val ts: List<@Contextual TemporalAmount?>? = null,
+            )
+
+            val nullTest: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(nullTest.ts).isNull()
+
+            val emptyTest: Conf = Config4k.decodeFromConfig("ts = []".toConfig())
+            assertThat(emptyTest.ts).isEmpty()
+
+            val test: Conf = Config4k.decodeFromConfig("ts = [1 y, null, 5 m]".toConfig())
+            assertThat(test.ts).containsExactly(OneYear, null, FiveMinutes)
+        }
+
+        @Test
+        fun checkDefaultDecoding() {
+            @Serializable
+            data class Conf(
+                val ts: List<@Contextual TemporalAmount> = listOf(FiveMinutes),
+            )
+
+            val test: Conf = Config4k.decodeFromConfig("ts = [5 m, 1 y]".toConfig())
+            assertThat(test.ts).containsExactly(FiveMinutes, OneYear)
+
+            val default: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(default.ts).containsExactly(FiveMinutes)
+        }
+
+        @Test
+        fun checkEncoding() {
+            @Serializable
+            data class Conf(
+                val ts: List<@Contextual TemporalAmount?>,
+            )
+
+            val test: Config = Config4k.encodeToConfig(Conf(listOf(FiveMinutes, null)))
+            assertThat(test.render()).isEqualTo("ts=[\"5 m\",null]")
+        }
+    }
+
+    @Nested
+    inner class MapTest {
+        @Test
+        fun checkDecoding() {
+            @Serializable
+            data class Conf(
+                val ts: Map<String, @Contextual TemporalAmount>,
+            )
+
+            val emptyTest: Conf = Config4k.decodeFromConfig("ts = {}".toConfig())
+            assertThat(emptyTest.ts).isEmpty()
+
+            val test: Conf = Config4k.decodeFromConfig("ts { key1 = 5 m, key2 = 1 y }".toConfig())
+            assertThat(test.ts)
+                .hasSize(2)
+                .containsEntry("key1", FiveMinutes)
+                .containsEntry("key2", OneYear)
+
+            val thrown =
+                catchThrowableOfType(MissingFieldException::class.java) { Config4k.decodeFromConfig<Conf>("foo = bar".toConfig()) }
+            assertThat(thrown.missingFields).contains("ts")
+        }
+
+        @Test
+        fun checkNullableDecoding() {
+            @Serializable
+            data class Conf(
+                val ts: Map<String, @Contextual TemporalAmount?>? = null,
+            )
+
+            val nullTest: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(nullTest.ts).isNull()
+
+            val emptyTest: Conf = Config4k.decodeFromConfig("ts = {}".toConfig())
+            assertThat(emptyTest.ts).isEmpty()
+
+            val test: Conf = Config4k.decodeFromConfig("ts = { key1 = 5 m, key2 = null, key3 = 1 y }".toConfig())
+            assertThat(test.ts)
+                .hasSize(3)
+                .containsEntry("key1", FiveMinutes)
+                .containsEntry("key2", null)
+                .containsEntry("key3", OneYear)
+        }
+
+        @Test
+        fun checkDefaultDecoding() {
+            @Serializable
+            data class Conf(
+                val ts: Map<String, @Contextual TemporalAmount> = mapOf("key" to FiveMinutes),
+            )
+
+            val test: Conf =
+                Config4k.decodeFromConfig("ts = { key1 = 5 m, key2 = 1 y }".toConfig())
+            assertThat(test.ts)
+                .hasSize(2)
+                .containsEntry("key1", FiveMinutes)
+                .containsEntry("key2", OneYear)
+
+            val default: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(default.ts).hasSize(1).containsEntry("key", FiveMinutes)
+        }
+
+        @Test
+        fun checkEncoding() {
+            @Serializable
+            data class Conf(
+                val ts: Map<String, @Contextual TemporalAmount?>,
+            )
+
+            val test: Config =
+                Config4k.encodeToConfig(
+                    Conf(
+                        mapOf(
+                            "key1" to FiveMinutes,
+                            "key2" to null,
+                        ),
+                    ),
+                )
+            assertThat(test.render()).isEqualTo("ts{key1=\"5 m\",key2=null}")
+        }
+    }
+}