Repository: config4k/config4k (kotlin)

Issue:
Support `Map` with key as object
To backward compatibility need to implement this case from docs:

```kotlin
val config =
    ConfigFactory.parseString(
        """
|map = [{  
|  key = 5
|  value = "foo"
|}
|{
|  key = 6
|  value = "bar"
|}]
        """.trimMargin(),
    )
val map: Map<Int, String> = config.extract("map")
println(map[5] == "foo") // true
println(map[6] == "bar") // true
```

Pull request:
Fixes #300

Patch:
diff --git a/api/config4k.api b/api/config4k.api
index d1efe79..5cc2ed2 100644
--- a/api/config4k.api
+++ b/api/config4k.api
@@ -83,6 +83,15 @@ public final class io/github/config4k/serializers/FileSerializer : kotlinx/seria
 	public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V
 }
 
+public final class io/github/config4k/serializers/MapAsListSerializer : kotlinx/serialization/KSerializer {
+	public fun <init> (Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;)V
+	public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object;
+	public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/util/Map;
+	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/util/Map;)V
+}
+
 public final class io/github/config4k/serializers/PathSerializer : kotlinx/serialization/KSerializer {
 	public static final field INSTANCE Lio/github/config4k/serializers/PathSerializer;
 	public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object;
diff --git a/src/main/kotlin/io/github/config4k/TypeAliases.kt b/src/main/kotlin/io/github/config4k/TypeAliases.kt
new file mode 100644
index 0000000..3cf8463
--- /dev/null
+++ b/src/main/kotlin/io/github/config4k/TypeAliases.kt
@@ -0,0 +1,10 @@
+@file:Suppress("ktlint:standard:filename")
+
+package io.github.config4k
+
+import io.github.config4k.serializers.MapAsListSerializer
+import kotlinx.serialization.Serializable
+
+public typealias MapAsList<K, V> =
+    @Serializable(with = MapAsListSerializer::class)
+    Map<K, V>
diff --git a/src/main/kotlin/io/github/config4k/serializers/MapAsListSerializer.kt b/src/main/kotlin/io/github/config4k/serializers/MapAsListSerializer.kt
new file mode 100644
index 0000000..cc4ef02
--- /dev/null
+++ b/src/main/kotlin/io/github/config4k/serializers/MapAsListSerializer.kt
@@ -0,0 +1,76 @@
+package io.github.config4k.serializers
+
+import com.typesafe.config.ConfigValueType.LIST
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.builtins.ListSerializer
+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
+
+/**
+ * Serializer for custom map, exposed as key value list.
+ *
+ * Usage example:
+ * ```
+ * @Serializable
+ * data class ExampleMapWithKeyValue(
+ *   @Serializable(MapAsListSerializer::class)
+ *   val regex: Map<Int, String>,
+ * )
+ * val config = ConfigFactory.parseString("regex = [{key = 5, value = foo},{key = 6, value = bar}]")
+ * val exampleMap: ExampleMapWithKeyValue = Hocon.decodeFromConfig(config)
+ * val newConfig = Hocon.encodeToConfig(ExampleMapWithKeyValue.serializer(), exampleMap)
+ * ```
+ */
+public class MapAsListSerializer<T, V>(
+    private val keySerializer: KSerializer<T>,
+    private val valueSerializer: KSerializer<V>,
+) : KSerializer<Map<T, V>> {
+    @Serializable
+    private data class SerializableItem<T, V>(
+        private val key: T,
+        private val value: V,
+    ) {
+        fun toPair() = key to value
+    }
+
+    override val descriptor: SerialDescriptor =
+        PrimitiveSerialDescriptor("hocon.kotlin.collections.MapAsList", PrimitiveKind.STRING)
+
+    override fun deserialize(decoder: Decoder): Map<T, V> =
+        if (decoder is HoconDecoder) {
+            decoder.decodeConfigValue { conf, path ->
+                when (val type = conf.getValue(path).valueType()) {
+                    LIST ->
+                        decoder
+                            .decodeSerializableValue(
+                                ListSerializer(SerializableItem.serializer(keySerializer, valueSerializer)),
+                            ).associate { it.toPair() }
+
+                    else -> throw SerializationException("MapAsList can't be specified by $type value type, only LIST supported")
+                }
+            }
+        } else {
+            throw SerializationException("This class can be decoded only by Hocon format")
+        }
+
+    override fun serialize(
+        encoder: Encoder,
+        value: Map<T, V>,
+    ) {
+        if (encoder is HoconEncoder) {
+            encoder.encodeSerializableValue(
+                ListSerializer(SerializableItem.serializer(keySerializer, valueSerializer)),
+                value.map { SerializableItem(key = it.key, value = it.value) },
+            )
+        } else {
+            throw SerializationException("This class can be encoded only by Hocon format")
+        }
+    }
+}


Test patch:
diff --git a/src/test/kotlin/io/github/config4k/serializers/MapAsListSerializerTest.kt b/src/test/kotlin/io/github/config4k/serializers/MapAsListSerializerTest.kt
new file mode 100644
index 0000000..5ab2c2a
--- /dev/null
+++ b/src/test/kotlin/io/github/config4k/serializers/MapAsListSerializerTest.kt
@@ -0,0 +1,142 @@
+package io.github.config4k.serializers
+
+import io.github.config4k.Config4k
+import io.github.config4k.MapAsList
+import io.github.config4k.render
+import io.github.config4k.serializers.MapAsListSerializerTest.Protocol.HTTP
+import io.github.config4k.serializers.MapAsListSerializerTest.Protocol.HTTPS
+import io.github.config4k.toConfig
+import io.kotest.matchers.should
+import io.mockk.mockk
+import kotlinx.serialization.Contextual
+import kotlinx.serialization.MissingFieldException
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.builtins.serializer
+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.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+import java.net.URL
+import java.util.UUID
+
+class MapAsListSerializerTest {
+    private companion object {
+        val simpleKeyMap = mapOf(5 to NestedConf(foo = "bar"), 0 to null, null to NestedConf(foo = "baz"))
+        val enumKeyMap = mapOf(HTTPS to "secured", HTTP to "unsecured")
+        val typedKeyMap = mapOf(UUID.fromString("b14240e7-acc0-4a19-bc5d-31a7901e36b0") to URL("https://www.example.com"))
+    }
+
+    @Serializable
+    private data class NestedConf(
+        val foo: String,
+    )
+
+    @Serializable
+    private enum class Protocol {
+        HTTPS,
+        HTTP,
+    }
+
+    @Test
+    fun checkSimpleKeyDecoding() {
+        @Serializable
+        data class Conf(
+            val map: MapAsList<Int?, NestedConf?>,
+        )
+
+        val test: Conf =
+            Config4k.decodeFromConfig(
+                """map = [
+                    |{key = 5, value = {"foo" = "bar"}},
+                    |{key = 0, value = null}, 
+                    |{key = null, value = {"foo" = "baz"}}
+                    |]
+                """.trimMargin()
+                    .toConfig(),
+            )
+
+        assertThat(test.map).isEqualTo(simpleKeyMap)
+
+        assertThatThrownBy {
+            Config4k.decodeFromConfig<Conf>("map = { foo = bar }".toConfig())
+        }.isInstanceOf(SerializationException::class.java)
+            .hasMessage("MapAsList can't be specified by OBJECT value type, only LIST supported")
+
+        assertThrows<MissingFieldException> { Config4k.decodeFromConfig<Conf>("value = bar".toConfig()) }
+            .should { assertThat(it.missingFields).contains("map") }
+    }
+
+    @Test
+    fun checkSimpleKeyEncoding() {
+        @Serializable
+        data class Conf(
+            val map: MapAsList<Int?, NestedConf?>,
+        )
+
+        var test = Config4k.encodeToConfig(Conf(simpleKeyMap))
+        assertThat(test.render()).isEqualTo("map=[{key=5,value{foo=bar}},{key=0,value=null},{key=null,value{foo=baz}}]")
+        test = Config4k.encodeToConfig(Conf(emptyMap()))
+        assertThat(test.render()).isEqualTo("map=[]")
+    }
+
+    @Test
+    fun checkEnumKeyDecoding() {
+        @Serializable
+        data class Conf(
+            val map: MapAsList<@Contextual Protocol, @Contextual String>,
+        )
+
+        val test: Conf =
+            Config4k.decodeFromConfig(
+                "map = [{key = HTTPS, value = secured}, {key = HTTP, value = unsecured}]".toConfig(),
+            )
+
+        assertThatThrownBy {
+            Config4k.decodeFromConfig<Conf>("map = [{ key = HTTP }]".toConfig())
+        }.isInstanceOf(SerializationException::class.java)
+            .hasMessage(
+                "Field 'value' is required for type with serial name 'io.github.config4k.serializers.MapAsListSerializer.SerializableItem', but it was missing",
+            )
+
+        assertThatThrownBy {
+            Config4k.decodeFromConfig<Conf>("map = [{ value = secured }]".toConfig())
+        }.isInstanceOf(SerializationException::class.java)
+            .hasMessage(
+                "Field 'key' is required for type with serial name 'io.github.config4k.serializers.MapAsListSerializer.SerializableItem', but it was missing",
+            )
+
+        assertThat(test.map).isEqualTo(enumKeyMap)
+    }
+
+    @Test
+    fun checkTypedMapDecoding() {
+        @Serializable
+        data class Conf(
+            val map: MapAsList<@Contextual UUID, @Contextual URL>,
+        )
+
+        val test: Conf =
+            Config4k.decodeFromConfig(
+                "map = [{key = b14240e7-acc0-4a19-bc5d-31a7901e36b0, value = \"https://www.example.com\"}]".toConfig(),
+            )
+
+        assertThat(test.map).isEqualTo(typedKeyMap)
+    }
+
+    @Test
+    fun checkIncorrectEncoder() {
+        assertThatThrownBy { MapAsListSerializer(String.serializer(), String.serializer()).serialize(mockk(), mockk()) }
+            .isInstanceOf(SerializationException::class.java)
+            .hasMessage("This class can be encoded only by Hocon format")
+    }
+
+    @Test
+    fun checkIncorrectDecoder() {
+        assertThatThrownBy { MapAsListSerializer(String.serializer(), String.serializer()).deserialize(mockk()) }
+            .isInstanceOf(SerializationException::class.java)
+            .hasMessage("This class can be decoded only by Hocon format")
+    }
+}