Skip to content

Commit

Permalink
HOCON: parse strings into integers and booleans if possible (#1795)
Browse files Browse the repository at this point in the history
HOCON suggests that parsers should apply certain automatic conversions, especially when reading integers/numbers or booleans from strings (https://github.com/lightbend/config/blob/main/HOCON.md#automatic-type-conversions).

This is an attempt to resolve the most pressing issues.
Fixes #1439

This PR changes parsing so that it now relies on the parsing capabilities for the Config class, and not on the obtained ConfigValues.

This PR does not claim to cover all automatic conversions mentioned but is focused on parsing numbers and booleans from strings only.

Co-authored-by: saibot <tobiaslieber@web.de>
  • Loading branch information
tobiaslieber and saibot authored Dec 20, 2021
1 parent a33ef02 commit 77aa167
Show file tree
Hide file tree
Showing 2 changed files with 94 additions and 25 deletions.
63 changes: 38 additions & 25 deletions formats/hocon/src/main/kotlin/kotlinx/serialization/hocon/Hocon.kt
Original file line number Diff line number Diff line change
Expand Up @@ -47,18 +47,28 @@ public sealed class Hocon(
override val serializersModule: SerializersModule
get() = this@Hocon.serializersModule

abstract fun getTaggedConfigValue(tag: T): ConfigValue

private inline fun <reified E : Any> validateAndCast(tag: T, wrappedType: ConfigValueType): E {
val cfValue = getTaggedConfigValue(tag)
if (cfValue.valueType() != wrappedType) throw SerializationException("${cfValue.origin().description()} required to be a $wrappedType")
return cfValue.unwrapped() as E
abstract fun <E> getValueFromTaggedConfig(tag: T, valueResolver: (Config, String) -> E): E

private inline fun <reified E : Any> validateAndCast(tag: T): E {
return try {
when (E::class) {
Number::class -> getValueFromTaggedConfig(tag) { config, path -> config.getNumber(path) } as E
Boolean::class -> getValueFromTaggedConfig(tag) { config, path -> config.getBoolean(path) } as E
String::class -> getValueFromTaggedConfig(tag) { config, path -> config.getString(path) } as E
else -> getValueFromTaggedConfig(tag) { config, path -> config.getAnyRef(path) } as E
}
} catch (e: ConfigException) {
val configOrigin = e.origin()
val requiredType = E::class.simpleName
throw SerializationException("${configOrigin.description()} required to be of type $requiredType")
}
}

private fun getTaggedNumber(tag: T) = validateAndCast<Number>(tag, ConfigValueType.NUMBER)
private fun getTaggedNumber(tag: T) = validateAndCast<Number>(tag)

override fun decodeTaggedString(tag: T) = validateAndCast<String>(tag, ConfigValueType.STRING)
override fun decodeTaggedString(tag: T) = validateAndCast<String>(tag)

override fun decodeTaggedBoolean(tag: T) = validateAndCast<Boolean>(tag)
override fun decodeTaggedByte(tag: T): Byte = getTaggedNumber(tag).toByte()
override fun decodeTaggedShort(tag: T): Short = getTaggedNumber(tag).toShort()
override fun decodeTaggedInt(tag: T): Int = getTaggedNumber(tag).toInt()
Expand All @@ -67,17 +77,17 @@ public sealed class Hocon(
override fun decodeTaggedDouble(tag: T): Double = getTaggedNumber(tag).toDouble()

override fun decodeTaggedChar(tag: T): Char {
val s = validateAndCast<String>(tag, ConfigValueType.STRING)
val s = validateAndCast<String>(tag)
if (s.length != 1) throw SerializationException("String \"$s\" is not convertible to Char")
return s[0]
}

override fun decodeTaggedValue(tag: T): Any = getTaggedConfigValue(tag).unwrapped()
override fun decodeTaggedValue(tag: T): Any = getValueFromTaggedConfig(tag) { c, s -> c.getAnyRef(s) }

override fun decodeTaggedNotNullMark(tag: T) = getTaggedConfigValue(tag).valueType() != ConfigValueType.NULL
override fun decodeTaggedNotNullMark(tag: T) = getValueFromTaggedConfig(tag) { c, s -> !c.getIsNull(s) }

override fun decodeTaggedEnum(tag: T, enumDescriptor: SerialDescriptor): Int {
val s = validateAndCast<String>(tag, ConfigValueType.STRING)
val s = validateAndCast<String>(tag)
return enumDescriptor.getElementIndexOrThrow(s)
}
}
Expand Down Expand Up @@ -107,14 +117,6 @@ public sealed class Hocon(
else originalName.replace(NAMING_CONVENTION_REGEX) { "-${it.value.lowercase()}" }
}

override fun getTaggedConfigValue(tag: String): ConfigValue {
return conf.getValue(tag)
}

override fun decodeTaggedNotNullMark(tag: String): Boolean {
return !conf.getIsNull(tag)
}

override fun decodeNotNullMark(): Boolean {
// Tag might be null for top-level deserialization
val currentTag = currentTagOrNull ?: return !conf.isEmpty
Expand Down Expand Up @@ -159,6 +161,10 @@ public sealed class Hocon(
else -> this
}
}

override fun <E> getValueFromTaggedConfig(tag: String, valueResolver: (Config, String) -> E): E {
return valueResolver(conf, tag)
}
}

private inner class ListConfigReader(private val list: ConfigList) : ConfigConverter<Int>() {
Expand All @@ -179,7 +185,11 @@ public sealed class Hocon(
return if (ind > list.size - 1) DECODE_DONE else ind
}

override fun getTaggedConfigValue(tag: Int): ConfigValue = list[tag]
override fun <E> getValueFromTaggedConfig(tag: Int, valueResolver: (Config, String) -> E): E {
val tagString = tag.toString()
val configValue = valueResolver(list[tag].atKey(tagString), tagString)
return configValue
}
}

private inner class MapConfigReader(map: ConfigObject) : ConfigConverter<Int>() {
Expand Down Expand Up @@ -210,13 +220,16 @@ public sealed class Hocon(
return if (ind >= indexSize) DECODE_DONE else ind
}

override fun getTaggedConfigValue(tag: Int): ConfigValue {
override fun <E> getValueFromTaggedConfig(tag: Int, valueResolver: (Config, String) -> E): E {
val idx = tag / 2
return if (tag % 2 == 0) { // entry as string
ConfigValueFactory.fromAnyRef(keys[idx])
val tagString = tag.toString()
val configValue = if (tag % 2 == 0) { // entry as string
ConfigValueFactory.fromAnyRef(keys[idx]).atKey(tagString)
} else {
values[idx]
val configValue = values[idx]
configValue.atKey(tagString)
}
return valueResolver(configValue, tagString)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

package kotlinx.serialization.hocon

import kotlin.test.*
import kotlinx.serialization.*
import kotlinx.serialization.builtins.*
import org.junit.*
Expand Down Expand Up @@ -31,6 +32,12 @@ class HoconValuesTest {
@Serializable
data class WithNullableList(val i1: List<Int?>, val i2: List<String>?, val i3: List<WithNullable?>?)

@Serializable
data class WithList(val i1: List<Int>)

@Serializable
data class WithMap(val m: Map<Int, Int>)

@Test
fun `deserialize numbers`() {
val conf = "b=42, s=1337, i=100500, l = 4294967294, f=0.0, d=-0.123"
Expand All @@ -45,6 +52,20 @@ class HoconValuesTest {
}
}

@Test
fun `deserialize numbers from strings`() {
val conf = """b="42", s="1337", i="100500", l = "4294967294", f="0.0", d="-0.123" """
val nums = deserializeConfig(conf, NumbersConfig.serializer())
with(nums) {
assertEquals(42.toByte(), b)
assertEquals(1337.toShort(), s)
assertEquals(100500, i)
assertEquals(4294967294L, l)
assertEquals(0.0f, f)
assertEquals(-0.123, d, 1e-9)
}
}

@Test
fun `deserialize string types`() {
val obj = deserializeConfig("c=f, s=foo", StringConfig.serializer())
Expand All @@ -59,6 +80,20 @@ class HoconValuesTest {
assertEquals(true, obj.b)
}

@Test
fun `unparseable data fails with exception`() {
val e = assertFailsWith<SerializationException> {
deserializeConfig("e = A, b=not-a-boolean", OtherConfig.serializer())
}
}

@Test
fun `deserialize other types from strings`() {
val obj = deserializeConfig("""e = "A", b="true" """, OtherConfig.serializer())
assertEquals(Choice.A, obj.e)
assertEquals(true, obj.b)
}

@Test
fun `deserialize default values`() {
val obj = deserializeConfig("", WithDefault.serializer())
Expand Down Expand Up @@ -103,4 +138,25 @@ class HoconValuesTest {
assertEquals(listOf(null, WithNullable(10, "bar")), i3)
}
}

@Test
fun `deserialize list of integer string values`() {
val configString = """i1 = [ "1","3" ]"""
val obj = deserializeConfig(configString, WithList.serializer())
assertEquals(listOf(1, 3), obj.i1)
}

@Test
fun `deserialize map with integers`() {
val configString = """m = { 2: 1, 4: 3 }"""
val obj = deserializeConfig(configString, WithMap.serializer())
assertEquals(mapOf(2 to 1, 4 to 3), obj.m)
}

@Test
fun `deserialize map with integers as strings`() {
val configString = """m = { "2": "1", "4":"3" }"""
val obj = deserializeConfig(configString, WithMap.serializer())
assertEquals(mapOf(2 to 1, 4 to 3), obj.m)
}
}

0 comments on commit 77aa167

Please sign in to comment.