Skip to content

Commit

Permalink
Add support for deserialization Duration in HOCON format (#2073)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
alexmihailov authored Oct 27, 2022
1 parent f451e43 commit 77c8232
Show file tree
Hide file tree
Showing 5 changed files with 178 additions and 13 deletions.
11 changes: 10 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
55 changes: 43 additions & 12 deletions formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/Hocon.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.*

Expand All @@ -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.
Expand Down Expand Up @@ -86,6 +93,18 @@ public sealed class Hocon(

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

@SuppressAnimalSniffer
protected fun <E> 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<String>(tag)

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

override fun <T> decodeSerializableValue(deserializer: DeserializationStrategy<T>): 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<T>).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<T>).deserialize(reader)
}

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

override fun <T> decodeSerializableValue(deserializer: DeserializationStrategy<T>): 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)
Expand Down Expand Up @@ -209,6 +235,11 @@ public sealed class Hocon(

private val indexSize = values.size * 2

override fun <T> decodeSerializableValue(deserializer: DeserializationStrategy<T>): 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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions formats/hocon/src/mainModule/kotlin/module-info.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Duration>)

@Serializable
data class ConfigMap(val mp: Map<String, Duration>)

@Serializable
data class ConfigMapDurationKey(val mp: Map<Duration, Duration>)

@Serializable
data class Complex(
val i: Int,
val s: Simple,
val n: Nullable,
val l: List<Simple>,
val ln: List<Nullable>,
val f: Boolean,
val ld: List<Duration>,
val mp: Map<String, Duration>,
val mpp: Map<Duration, Duration>
)

@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<SerializationException>(message) {
deserializeConfig("d = 10 unknown", Simple.serializer())
}
}
}

0 comments on commit 77c8232

Please sign in to comment.