Repository: config4k/config4k (kotlin)
Issue: `java.net.URL` and `java.net.URI` should be serializable
Pull request description: Fixes #299

Patch:
diff --git a/api/config4k.api b/api/config4k.api
index 7c80f87..be1a224 100644
--- a/api/config4k.api
+++ b/api/config4k.api
@@ -119,6 +119,24 @@ public final class io/github/config4k/serializers/TemporalAmountSerializer : kot
 	public fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/time/temporal/TemporalAmount;)V
 }
 
+public final class io/github/config4k/serializers/URISerializer : kotlinx/serialization/KSerializer {
+	public static final field INSTANCE Lio/github/config4k/serializers/URISerializer;
+	public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object;
+	public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/net/URI;
+	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/net/URI;)V
+}
+
+public final class io/github/config4k/serializers/URLSerializer : kotlinx/serialization/KSerializer {
+	public static final field INSTANCE Lio/github/config4k/serializers/URLSerializer;
+	public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object;
+	public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/net/URL;
+	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/net/URL;)V
+}
+
 public final class io/github/config4k/serializers/UUIDSerializer : kotlinx/serialization/KSerializer {
 	public static final field INSTANCE Lio/github/config4k/serializers/UUIDSerializer;
 	public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object;
diff --git a/src/main/kotlin/io/github/config4k/Config4k.kt b/src/main/kotlin/io/github/config4k/Config4k.kt
index e86f5d0..89e47e1 100644
--- a/src/main/kotlin/io/github/config4k/Config4k.kt
+++ b/src/main/kotlin/io/github/config4k/Config4k.kt
@@ -7,6 +7,8 @@ import io.github.config4k.serializers.PatternSerializer
 import io.github.config4k.serializers.PeriodSerializer
 import io.github.config4k.serializers.RegexSerializer
 import io.github.config4k.serializers.TemporalAmountSerializer
+import io.github.config4k.serializers.URISerializer
+import io.github.config4k.serializers.URLSerializer
 import io.github.config4k.serializers.UUIDSerializer
 import kotlinx.serialization.hocon.Hocon
 import kotlinx.serialization.hocon.serializers.ConfigMemorySizeSerializer
@@ -22,6 +24,8 @@ public val Config4kModule: SerializersModule =
         contextual(RegexSerializer)
         contextual(PatternSerializer)
         contextual(TemporalAmountSerializer)
+        contextual(URISerializer)
+        contextual(URLSerializer)
         contextual(UUIDSerializer)
         contextual(ConfigSerializer)
         contextual(ConfigValueSerializer)
diff --git a/src/main/kotlin/io/github/config4k/serializers/URISerializer.kt b/src/main/kotlin/io/github/config4k/serializers/URISerializer.kt
new file mode 100644
index 0000000..85bbf8c
--- /dev/null
+++ b/src/main/kotlin/io/github/config4k/serializers/URISerializer.kt
@@ -0,0 +1,21 @@
+package io.github.config4k.serializers
+
+import kotlinx.serialization.KSerializer
+import java.net.URI
+
+/**
+ * Serializer for [URI]
+ *
+ * Usage example:
+ * ```
+ * @Serializable
+ * data class ExampleURI(
+ *   @Serializable(URISerializer::class)
+ *   val uri: URI,
+ * )
+ * val config = ConfigFactory.parseString("uri = "ssh://192.0.2.16:22/"")
+ * val exampleURI: ExampleURI = Hocon.decodeFromConfig(config)
+ * val newConfig = Hocon.encodeToConfig(ExampleURI.serializer(), exampleURI)
+ * ```
+ */
+public object URISerializer : KSerializer<URI> by stringSerializer("hocon.${URI::class.qualifiedName}", ::URI, URI::toASCIIString)
diff --git a/src/main/kotlin/io/github/config4k/serializers/URLSerializer.kt b/src/main/kotlin/io/github/config4k/serializers/URLSerializer.kt
new file mode 100644
index 0000000..5f440b2
--- /dev/null
+++ b/src/main/kotlin/io/github/config4k/serializers/URLSerializer.kt
@@ -0,0 +1,21 @@
+package io.github.config4k.serializers
+
+import kotlinx.serialization.KSerializer
+import java.net.URL
+
+/**
+ * Serializer for [URL]
+ *
+ * Usage example:
+ * ```
+ * @Serializable
+ * data class ExampleURL(
+ *   @Serializable(URLSerializer::class)
+ *   val url: URL,
+ * )
+ * val config = ConfigFactory.parseString("url = "https://www.example.com"")
+ * val exampleURL: ExampleURL = Hocon.decodeFromConfig(config)
+ * val newConfig = Hocon.encodeToConfig(ExampleURL.serializer(), exampleURL)
+ * ```
+ */
+public object URLSerializer : KSerializer<URL> by stringSerializer("hocon.${URL::class.qualifiedName}", ::URL, URL::toExternalForm)


Test patch:
diff --git a/src/test/kotlin/io/github/config4k/serializers/URISerializerTest.kt b/src/test/kotlin/io/github/config4k/serializers/URISerializerTest.kt
new file mode 100644
index 0000000..5a8977f
--- /dev/null
+++ b/src/test/kotlin/io/github/config4k/serializers/URISerializerTest.kt
@@ -0,0 +1,259 @@
+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.net.URI
+
+class URISerializerTest {
+    private companion object {
+        val uri: URI = URI("example://192.0.2.16:22/")
+        val defaultUri: URI = URI("default://192.0.2.16:22/")
+    }
+
+    @Nested
+    inner class SimpleTest {
+        @Test
+        fun checkDecoding() {
+            @Serializable
+            data class Conf(
+                @Contextual val uri: URI,
+            )
+
+            val test: Conf = Config4k.decodeFromConfig("uri = \"example://192.0.2.16:22/\"".toConfig())
+            assertThat(test.uri).isEqualTo(uri)
+
+            val thrown =
+                catchThrowableOfType(MissingFieldException::class.java) {
+                    Config4k.decodeFromConfig<Conf>("foo = bar".toConfig())
+                }
+            assertThat(thrown.missingFields).contains("uri")
+        }
+
+        @Test
+        fun checkIncorrectDecoder() {
+            assertThatThrownBy { URISerializer.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 uri: URI? = null,
+            )
+
+            val test: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(test.uri).isNull()
+        }
+
+        @Test
+        fun checkDefaultDecoding() {
+            @Serializable
+            data class Conf(
+                @Contextual val uri: URI = defaultUri,
+            )
+
+            val test: Conf = Config4k.decodeFromConfig("uri = \"example://192.0.2.16:22/\"".toConfig())
+            assertThat(test.uri).isEqualTo(uri)
+
+            val default: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(default.uri).isEqualTo(defaultUri)
+        }
+
+        @Test
+        fun checkEncoding() {
+            @Serializable
+            data class Conf(
+                @Contextual val uri: URI,
+            )
+
+            val test = Config4k.encodeToConfig(Conf(uri))
+            assertThat(test.render()).isEqualTo("uri=\"example://192.0.2.16:22/\"")
+        }
+
+        @Test
+        fun checkIncorrectEncoder() {
+            assertThatThrownBy { URISerializer.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 uris: List<@Contextual URI>,
+            )
+
+            val emptyTest: Conf = Config4k.decodeFromConfig("uris = []".toConfig())
+            assertThat(emptyTest.uris).isEmpty()
+
+            val test: Conf =
+                Config4k.decodeFromConfig(
+                    "uris = [\"example://192.0.2.16:22/\", \"default://192.0.2.16:22/\"]".toConfig(),
+                )
+            assertThat(test.uris).containsExactly(uri, defaultUri)
+
+            val thrown =
+                catchThrowableOfType(MissingFieldException::class.java) { Config4k.decodeFromConfig<Conf>("foo = bar".toConfig()) }
+            assertThat(thrown.missingFields).contains("uris")
+        }
+
+        @Test
+        fun checkNullableDecoding() {
+            @Serializable
+            data class Conf(
+                val uris: List<@Contextual URI?>? = null,
+            )
+
+            val nullTest: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(nullTest.uris).isNull()
+
+            val emptyTest: Conf = Config4k.decodeFromConfig("uris = []".toConfig())
+            assertThat(emptyTest.uris).isEmpty()
+
+            val test: Conf =
+                Config4k.decodeFromConfig(
+                    "uris = [\"example://192.0.2.16:22/\", null, \"default://192.0.2.16:22/\"]".toConfig(),
+                )
+            assertThat(test.uris).containsExactly(uri, null, defaultUri)
+        }
+
+        @Test
+        fun checkDefaultDecoding() {
+            @Serializable
+            data class Conf(
+                val uris: List<@Contextual URI> = listOf(defaultUri),
+            )
+
+            val test: Conf =
+                Config4k.decodeFromConfig(
+                    "uris = [\"example://192.0.2.16:22/\", \"default://192.0.2.16:22/\"]".toConfig(),
+                )
+            assertThat(test.uris).containsExactly(uri, defaultUri)
+
+            val default: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(default.uris).containsExactly(defaultUri)
+        }
+
+        @Test
+        fun checkEncoding() {
+            @Serializable
+            data class Conf(
+                val uris: List<@Contextual URI?>,
+            )
+
+            val test: Config = Config4k.encodeToConfig(Conf(listOf(uri, null)))
+            assertThat(test.render()).isEqualTo("uris=[\"example://192.0.2.16:22/\",null]")
+        }
+    }
+
+    @Nested
+    inner class MapTest {
+        @Test
+        fun checkDecoding() {
+            @Serializable
+            data class Conf(
+                val uris: Map<String, @Contextual URI>,
+            )
+
+            val emptyTest: Conf = Config4k.decodeFromConfig("uris = {}".toConfig())
+            assertThat(emptyTest.uris).isEmpty()
+
+            val test: Conf =
+                Config4k.decodeFromConfig(
+                    "uris { key1 = \"example://192.0.2.16:22/\", key2 = \"default://192.0.2.16:22/\" }".toConfig(),
+                )
+            assertThat(test.uris)
+                .hasSize(2)
+                .containsEntry("key1", uri)
+                .containsEntry("key2", defaultUri)
+
+            val thrown =
+                catchThrowableOfType(MissingFieldException::class.java) { Config4k.decodeFromConfig<Conf>("foo = bar".toConfig()) }
+            assertThat(thrown.missingFields).contains("uris")
+        }
+
+        @Test
+        fun checkNullableDecoding() {
+            @Serializable
+            data class Conf(
+                val uris: Map<String, @Contextual URI?>? = null,
+            )
+
+            val nullTest: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(nullTest.uris).isNull()
+
+            val emptyTest: Conf = Config4k.decodeFromConfig("uris = {}".toConfig())
+            assertThat(emptyTest.uris).isEmpty()
+
+            val test: Conf =
+                Config4k.decodeFromConfig(
+                    "uris { key1 = \"example://192.0.2.16:22/\", key2 = null, key3 = \"default://192.0.2.16:22/\" }"
+                        .toConfig(),
+                )
+            assertThat(test.uris)
+                .hasSize(3)
+                .containsEntry("key1", uri)
+                .containsEntry("key2", null)
+                .containsEntry("key3", defaultUri)
+        }
+
+        @Test
+        fun checkDefaultDecoding() {
+            @Serializable
+            data class Conf(
+                val uris: Map<String, @Contextual URI> = mapOf("key" to defaultUri),
+            )
+
+            val test: Conf =
+                Config4k.decodeFromConfig(
+                    "uris = { key1 = \"example://192.0.2.16:22/\", key2 = \"default://192.0.2.16:22/\" }".toConfig(),
+                )
+            assertThat(test.uris)
+                .hasSize(2)
+                .containsEntry("key1", uri)
+                .containsEntry("key2", defaultUri)
+
+            val default: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(default.uris).hasSize(1).containsEntry("key", defaultUri)
+        }
+
+        @Test
+        fun checkEncoding() {
+            @Serializable
+            data class Conf(
+                val uris: Map<String, @Contextual URI?>,
+            )
+
+            val test: Config =
+                Config4k.encodeToConfig(
+                    Conf(
+                        mapOf(
+                            "key1" to defaultUri,
+                            "key2" to null,
+                        ),
+                    ),
+                )
+            assertThat(test.render()).isEqualTo("uris{key1=\"default://192.0.2.16:22/\",key2=null}")
+        }
+    }
+}
diff --git a/src/test/kotlin/io/github/config4k/serializers/URLSerializerTest.kt b/src/test/kotlin/io/github/config4k/serializers/URLSerializerTest.kt
new file mode 100644
index 0000000..160d01c
--- /dev/null
+++ b/src/test/kotlin/io/github/config4k/serializers/URLSerializerTest.kt
@@ -0,0 +1,259 @@
+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.net.URL
+
+class URLSerializerTest {
+    private companion object {
+        val url: URL = URL("https://www.example.com")
+        val defaultUrl: URL = URL("https://www.default.com")
+    }
+
+    @Nested
+    inner class SimpleTest {
+        @Test
+        fun checkDecoding() {
+            @Serializable
+            data class Conf(
+                @Contextual val url: URL,
+            )
+
+            val test: Conf = Config4k.decodeFromConfig("url = \"https://www.example.com\"".toConfig())
+            assertThat(test.url).isEqualTo(url)
+
+            val thrown =
+                catchThrowableOfType(MissingFieldException::class.java) {
+                    Config4k.decodeFromConfig<Conf>("foo = bar".toConfig())
+                }
+            assertThat(thrown.missingFields).contains("url")
+        }
+
+        @Test
+        fun checkIncorrectDecoder() {
+            assertThatThrownBy { URLSerializer.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 url: URL? = null,
+            )
+
+            val test: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(test.url).isNull()
+        }
+
+        @Test
+        fun checkDefaultDecoding() {
+            @Serializable
+            data class Conf(
+                @Contextual val url: URL = defaultUrl,
+            )
+
+            val test: Conf = Config4k.decodeFromConfig("url = \"https://www.example.com\"".toConfig())
+            assertThat(test.url).isEqualTo(url)
+
+            val default: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(default.url).isEqualTo(defaultUrl)
+        }
+
+        @Test
+        fun checkEncoding() {
+            @Serializable
+            data class Conf(
+                @Contextual val url: URL,
+            )
+
+            val test = Config4k.encodeToConfig(Conf(url))
+            assertThat(test.render()).isEqualTo("url=\"https://www.example.com\"")
+        }
+    }
+
+    @Nested
+    inner class ListTest {
+        @Test
+        fun checkDecoding() {
+            @Serializable
+            data class Conf(
+                val urls: List<@Contextual URL>,
+            )
+
+            val emptyTest: Conf = Config4k.decodeFromConfig("urls = []".toConfig())
+            assertThat(emptyTest.urls).isEmpty()
+
+            val test: Conf =
+                Config4k.decodeFromConfig(
+                    "urls = [\"https://www.example.com\", \"https://www.default.com\"]".toConfig(),
+                )
+            assertThat(test.urls).containsExactly(url, defaultUrl)
+
+            val thrown =
+                catchThrowableOfType(MissingFieldException::class.java) { Config4k.decodeFromConfig<Conf>("foo = bar".toConfig()) }
+            assertThat(thrown.missingFields).contains("urls")
+        }
+
+        @Test
+        fun checkNullableDecoding() {
+            @Serializable
+            data class Conf(
+                val urls: List<@Contextual URL?>? = null,
+            )
+
+            val nullTest: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(nullTest.urls).isNull()
+
+            val emptyTest: Conf = Config4k.decodeFromConfig("urls = []".toConfig())
+            assertThat(emptyTest.urls).isEmpty()
+
+            val test: Conf =
+                Config4k.decodeFromConfig(
+                    "urls = [\"https://www.example.com\", null, \"https://www.default.com\"]".toConfig(),
+                )
+            assertThat(test.urls).containsExactly(url, null, defaultUrl)
+        }
+
+        @Test
+        fun checkDefaultDecoding() {
+            @Serializable
+            data class Conf(
+                val urls: List<@Contextual URL> = listOf(defaultUrl),
+            )
+
+            val test: Conf =
+                Config4k.decodeFromConfig(
+                    "urls = [\"https://www.example.com\", \"https://www.default.com\"]".toConfig(),
+                )
+            assertThat(test.urls).containsExactly(url, defaultUrl)
+
+            val default: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(default.urls).containsExactly(defaultUrl)
+        }
+
+        @Test
+        fun checkEncoding() {
+            @Serializable
+            data class Conf(
+                val urls: List<@Contextual URL?>,
+            )
+
+            val test: Config = Config4k.encodeToConfig(Conf(listOf(url, null)))
+            assertThat(test.render()).isEqualTo("urls=[\"https://www.example.com\",null]")
+        }
+
+        @Test
+        fun checkIncorrectEncoder() {
+            assertThatThrownBy { URLSerializer.serialize(mockk(), mockk()) }
+                .isInstanceOf(SerializationException::class.java)
+                .hasMessage("This class can be encoded only by Hocon format")
+        }
+    }
+
+    @Nested
+    inner class MapTest {
+        @Test
+        fun checkDecoding() {
+            @Serializable
+            data class Conf(
+                val urls: Map<String, @Contextual URL>,
+            )
+
+            val emptyTest: Conf = Config4k.decodeFromConfig("urls = {}".toConfig())
+            assertThat(emptyTest.urls).isEmpty()
+
+            val test: Conf =
+                Config4k.decodeFromConfig(
+                    "urls { key1 = \"https://www.example.com\", key2 = \"https://www.default.com\" }".toConfig(),
+                )
+            assertThat(test.urls)
+                .hasSize(2)
+                .containsEntry("key1", url)
+                .containsEntry("key2", defaultUrl)
+
+            val thrown =
+                catchThrowableOfType(MissingFieldException::class.java) { Config4k.decodeFromConfig<Conf>("foo = bar".toConfig()) }
+            assertThat(thrown.missingFields).contains("urls")
+        }
+
+        @Test
+        fun checkNullableDecoding() {
+            @Serializable
+            data class Conf(
+                val urls: Map<String, @Contextual URL?>? = null,
+            )
+
+            val nullTest: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(nullTest.urls).isNull()
+
+            val emptyTest: Conf = Config4k.decodeFromConfig("urls = {}".toConfig())
+            assertThat(emptyTest.urls).isEmpty()
+
+            val test: Conf =
+                Config4k.decodeFromConfig(
+                    "urls { key1 = \"https://www.example.com\", key2 = null, key3 = \"https://www.default.com\" }"
+                        .toConfig(),
+                )
+            assertThat(test.urls)
+                .hasSize(3)
+                .containsEntry("key1", url)
+                .containsEntry("key2", null)
+                .containsEntry("key3", defaultUrl)
+        }
+
+        @Test
+        fun checkDefaultDecoding() {
+            @Serializable
+            data class Conf(
+                val urls: Map<String, @Contextual URL> = mapOf("key" to defaultUrl),
+            )
+
+            val test: Conf =
+                Config4k.decodeFromConfig(
+                    "urls = { key1 = \"https://www.example.com\", key2 = \"https://www.default.com\" }".toConfig(),
+                )
+            assertThat(test.urls)
+                .hasSize(2)
+                .containsEntry("key1", url)
+                .containsEntry("key2", defaultUrl)
+
+            val default: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(default.urls).hasSize(1).containsEntry("key", defaultUrl)
+        }
+
+        @Test
+        fun checkEncoding() {
+            @Serializable
+            data class Conf(
+                val urls: Map<String, @Contextual URL?>,
+            )
+
+            val test: Config =
+                Config4k.encodeToConfig(
+                    Conf(
+                        mapOf(
+                            "key1" to defaultUrl,
+                            "key2" to null,
+                        ),
+                    ),
+                )
+            assertThat(test.render()).isEqualTo("urls{key1=\"https://www.default.com\",key2=null}")
+        }
+    }
+}