Skip to content

Commit 77c8232

Browse files
authored
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.
1 parent f451e43 commit 77c8232

File tree

5 files changed

+178
-13
lines changed

5 files changed

+178
-13
lines changed

build.gradle

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,16 @@ subprojects {
175175
afterEvaluate { // Can be applied only when the project is evaluated
176176
animalsniffer {
177177
sourceSets = [sourceSets.main]
178-
annotation = (name == "kotlinx-serialization-core")? "kotlinx.serialization.internal.SuppressAnimalSniffer" : "kotlinx.serialization.json.internal.SuppressAnimalSniffer"
178+
def annotationValue = "kotlinx.serialization.json.internal.SuppressAnimalSniffer"
179+
switch (name) {
180+
case "kotlinx-serialization-core":
181+
annotationValue = "kotlinx.serialization.internal.SuppressAnimalSniffer"
182+
break
183+
case "kotlinx-serialization-hocon":
184+
annotationValue = "kotlinx.serialization.hocon.internal.SuppressAnimalSniffer"
185+
break
186+
}
187+
annotation = annotationValue
179188
}
180189
dependencies {
181190
signature 'net.sf.androidscents.signature:android-api-level-14:4.0_r4@signature'

formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/Hocon.kt

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@
55
package kotlinx.serialization.hocon
66

77
import com.typesafe.config.*
8+
import kotlin.time.*
89
import kotlinx.serialization.*
10+
import kotlinx.serialization.builtins.*
911
import kotlinx.serialization.descriptors.*
1012
import kotlinx.serialization.encoding.*
1113
import kotlinx.serialization.encoding.CompositeDecoder.Companion.DECODE_DONE
14+
import kotlinx.serialization.hocon.internal.SuppressAnimalSniffer
1215
import kotlinx.serialization.internal.*
1316
import kotlinx.serialization.modules.*
1417

@@ -19,6 +22,10 @@ import kotlinx.serialization.modules.*
1922
* [Config] object represents "Human-Optimized Config Object Notation" —
2023
* [HOCON][https://github.com/lightbend/config#using-hocon-the-json-superset].
2124
*
25+
* [Duration] objects are decoded using "HOCON duration format" -
26+
* [Duration format][https://github.com/lightbend/config/blob/main/HOCON.md#duration-format]
27+
* [Duration] objects encoding does not currently support duration HOCON format and uses standard Duration serializer which produces ISO-8601-2 string.
28+
*
2229
* @param [useConfigNamingConvention] switches naming resolution to config naming convention (hyphen separated).
2330
* @param serializersModule A [SerializersModule] which should contain registered serializers
2431
* for [Contextual] and [Polymorphic] serialization, if you have any.
@@ -86,6 +93,18 @@ public sealed class Hocon(
8693

8794
private fun getTaggedNumber(tag: T) = validateAndCast<Number>(tag)
8895

96+
@SuppressAnimalSniffer
97+
protected fun <E> decodeDurationInHoconFormat(tag: T): E {
98+
@Suppress("UNCHECKED_CAST")
99+
return getValueFromTaggedConfig(tag) { conf, path ->
100+
try {
101+
conf.getDuration(path).toKotlinDuration()
102+
} catch (e: ConfigException) {
103+
throw SerializationException("Value at $path cannot be read as kotlin.Duration because it is not a valid HOCON duration value", e)
104+
}
105+
} as E
106+
}
107+
89108
override fun decodeTaggedString(tag: T) = validateAndCast<String>(tag)
90109

91110
override fun decodeTaggedBoolean(tag: T) = validateAndCast<Boolean>(tag)
@@ -138,19 +157,21 @@ public sealed class Hocon(
138157
}
139158

140159
override fun <T> decodeSerializableValue(deserializer: DeserializationStrategy<T>): T {
141-
if (deserializer !is AbstractPolymorphicSerializer<*> || useArrayPolymorphism) {
142-
return deserializer.deserialize(this)
160+
return when {
161+
deserializer.descriptor == Duration.serializer().descriptor -> decodeDurationInHoconFormat(currentTag)
162+
deserializer !is AbstractPolymorphicSerializer<*> || useArrayPolymorphism -> deserializer.deserialize(this)
163+
else -> {
164+
val config = if (currentTagOrNull != null) conf.getConfig(currentTag) else conf
165+
166+
val reader = ConfigReader(config)
167+
val type = reader.decodeTaggedString(classDiscriminator)
168+
val actualSerializer = deserializer.findPolymorphicSerializerOrNull(reader, type)
169+
?: throw SerializerNotFoundException(type)
170+
171+
@Suppress("UNCHECKED_CAST")
172+
(actualSerializer as DeserializationStrategy<T>).deserialize(reader)
173+
}
143174
}
144-
145-
val config = if (currentTagOrNull != null) conf.getConfig(currentTag) else conf
146-
147-
val reader = ConfigReader(config)
148-
val type = reader.decodeTaggedString(classDiscriminator)
149-
val actualSerializer = deserializer.findPolymorphicSerializerOrNull(reader, type)
150-
?: throw SerializerNotFoundException(type)
151-
152-
@Suppress("UNCHECKED_CAST")
153-
return (actualSerializer as DeserializationStrategy<T>).deserialize(reader)
154175
}
155176

156177
override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder {
@@ -174,6 +195,11 @@ public sealed class Hocon(
174195
private inner class ListConfigReader(private val list: ConfigList) : ConfigConverter<Int>() {
175196
private var ind = -1
176197

198+
override fun <T> decodeSerializableValue(deserializer: DeserializationStrategy<T>): T = when (deserializer.descriptor) {
199+
Duration.serializer().descriptor -> decodeDurationInHoconFormat(ind)
200+
else -> super.decodeSerializableValue(deserializer)
201+
}
202+
177203
override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder =
178204
when {
179205
descriptor.kind.listLike -> ListConfigReader(list[currentTag] as ConfigList)
@@ -209,6 +235,11 @@ public sealed class Hocon(
209235

210236
private val indexSize = values.size * 2
211237

238+
override fun <T> decodeSerializableValue(deserializer: DeserializationStrategy<T>): T = when (deserializer.descriptor) {
239+
Duration.serializer().descriptor -> decodeDurationInHoconFormat(ind)
240+
else -> super.decodeSerializableValue(deserializer)
241+
}
242+
212243
override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder =
213244
when {
214245
descriptor.kind.listLike -> ListConfigReader(values[currentTag / 2] as ConfigList)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package kotlinx.serialization.hocon.internal
2+
3+
/**
4+
* Suppresses Animal Sniffer plugin errors for certain methods.
5+
* Such methods include references to Java 8 methods that are not
6+
* available in Android API, but can be desugared by R8.
7+
*/
8+
@Retention(AnnotationRetention.BINARY)
9+
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
10+
internal annotation class SuppressAnimalSniffer

formats/hocon/src/mainModule/kotlin/module-info.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
module kotlinx.serialization.hocon {
22
requires transitive kotlin.stdlib;
33
requires transitive kotlinx.serialization.core;
4+
requires transitive kotlin.stdlib.jdk8;
45
requires transitive typesafe.config;
56

67
exports kotlinx.serialization.hocon;
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package kotlinx.serialization.hocon
2+
3+
import kotlin.test.*
4+
import kotlin.time.*
5+
import kotlin.time.Duration.Companion.days
6+
import kotlin.time.Duration.Companion.hours
7+
import kotlin.time.Duration.Companion.milliseconds
8+
import kotlin.time.Duration.Companion.minutes
9+
import kotlin.time.Duration.Companion.nanoseconds
10+
import kotlin.time.Duration.Companion.seconds
11+
import kotlinx.serialization.*
12+
import org.junit.Assert.*
13+
import org.junit.Test
14+
15+
class HoconDurationDeserializerTest {
16+
17+
@Serializable
18+
data class Simple(val d: Duration)
19+
20+
@Serializable
21+
data class Nullable(val d: Duration?)
22+
23+
@Serializable
24+
data class ConfigList(val ld: List<Duration>)
25+
26+
@Serializable
27+
data class ConfigMap(val mp: Map<String, Duration>)
28+
29+
@Serializable
30+
data class ConfigMapDurationKey(val mp: Map<Duration, Duration>)
31+
32+
@Serializable
33+
data class Complex(
34+
val i: Int,
35+
val s: Simple,
36+
val n: Nullable,
37+
val l: List<Simple>,
38+
val ln: List<Nullable>,
39+
val f: Boolean,
40+
val ld: List<Duration>,
41+
val mp: Map<String, Duration>,
42+
val mpp: Map<Duration, Duration>
43+
)
44+
45+
@Test
46+
fun testDeserializeDurationInHoconFormat() {
47+
var obj = deserializeConfig("d = 10s", Simple.serializer())
48+
assertEquals(10.seconds, obj.d)
49+
obj = deserializeConfig("d = 10 hours", Simple.serializer())
50+
assertEquals(10.hours, obj.d)
51+
obj = deserializeConfig("d = 5 ms", Simple.serializer())
52+
assertEquals(5.milliseconds, obj.d)
53+
}
54+
55+
@Test
56+
fun testDeserializeNullableDurationInHoconFormat() {
57+
var obj = deserializeConfig("d = null", Nullable.serializer())
58+
assertNull(obj.d)
59+
60+
obj = deserializeConfig("d = 5 days", Nullable.serializer())
61+
assertEquals(5.days, obj.d!!)
62+
}
63+
64+
@Test
65+
fun testDeserializeListOfDurationInHoconFormat() {
66+
val obj = deserializeConfig("ld: [ 1d, 1m, 5ns ]", ConfigList.serializer())
67+
assertEquals(listOf(1.days, 1.minutes, 5.nanoseconds), obj.ld)
68+
}
69+
70+
@Test
71+
fun testDeserializeMapOfDurationInHoconFormat() {
72+
val obj = deserializeConfig("""
73+
mp: { day = 2d, hour = 5 hours, minute = 3 minutes }
74+
""".trimIndent(), ConfigMap.serializer())
75+
assertEquals(mapOf("day" to 2.days, "hour" to 5.hours, "minute" to 3.minutes), obj.mp)
76+
77+
val objDurationKey = deserializeConfig("""
78+
mp: { 1 hour = 3600s }
79+
""".trimIndent(), ConfigMapDurationKey.serializer())
80+
assertEquals(mapOf(1.hours to 3600.seconds), objDurationKey.mp)
81+
}
82+
83+
@Test
84+
fun testDeserializeComplexDurationInHoconFormat() {
85+
val obj = deserializeConfig("""
86+
i = 6
87+
s: { d = 5m }
88+
n: { d = null }
89+
l: [ { d = 1m }, { d = 2s } ]
90+
ln: [ { d = null }, { d = 6h } ]
91+
f = true
92+
ld: [ 1d, 1m, 5ns ]
93+
mp: { day = 2d, hour = 5 hours, minute = 3 minutes }
94+
mpp: { 1 hour = 3600s }
95+
""".trimIndent(), Complex.serializer())
96+
assertEquals(5.minutes, obj.s.d)
97+
assertNull(obj.n.d)
98+
assertEquals(listOf(Simple(1.minutes), Simple(2.seconds)), obj.l)
99+
assertEquals(listOf(Nullable(null), Nullable(6.hours)), obj.ln)
100+
assertEquals(6, obj.i)
101+
assertTrue(obj.f)
102+
assertEquals(listOf(1.days, 1.minutes, 5.nanoseconds), obj.ld)
103+
assertEquals(mapOf("day" to 2.days, "hour" to 5.hours, "minute" to 3.minutes), obj.mp)
104+
assertEquals(mapOf(1.hours to 3600.seconds), obj.mpp)
105+
}
106+
107+
@Test
108+
fun testThrowsWhenNotTimeUnitHocon() {
109+
val message = "Value at d cannot be read as kotlin.Duration because it is not a valid HOCON duration value"
110+
assertFailsWith<SerializationException>(message) {
111+
deserializeConfig("d = 10 unknown", Simple.serializer())
112+
}
113+
}
114+
}

0 commit comments

Comments
 (0)