Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for deserialization Duration in HOCON format #2073

Merged
merged 2 commits into from
Oct 27, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 decodes using "HOCON duration format" -
alexmihailov marked this conversation as resolved.
Show resolved Hide resolved
* [Duration format][https://github.com/lightbend/config/blob/main/HOCON.md#duration-format]
* [Duration] objects encodes does not currently support duration HOCON format and encodes to ISO-8601-2.
alexmihailov marked this conversation as resolved.
Show resolved Hide resolved
*
* @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())
}
}
}