Created
May 12, 2021 06:59
-
-
Save mikehearn/6fb28522265da653d7c67963866136b3 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package hydraulic.utils.config | |
import com.natpryce.hamkrest.isBlank | |
import com.typesafe.config.* | |
import com.typesafe.config.ConfigException.BadValue | |
import hydraulic.kotlin.utils.text.camelToKebabCase | |
import hydraulic.utils.app.UserInput | |
import java.lang.reflect.ParameterizedType | |
import java.lang.reflect.Type | |
import java.lang.reflect.WildcardType | |
import java.net.URI | |
import java.nio.file.Path | |
import java.time.Duration | |
import java.time.Period | |
import java.time.temporal.TemporalAmount | |
import kotlin.properties.PropertyDelegateProvider | |
import kotlin.properties.ReadOnlyProperty | |
import kotlin.reflect.KProperty | |
/** | |
* The usual JVM generics hack to find out a type variable. Instantiate an anonymous object that subclasses this and then you can read | |
* the generics information from [type]. | |
*/ | |
public open class TypeHolder<T> { | |
/** Returns the generic type argument T - you probably want to cast it to [ParameterizedType] if the argument is itself parametric.. */ | |
public val type: Type = (this.javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[0] | |
} | |
public inline fun <reified T> Config.read(path: String): T? = read(path, object : TypeHolder<T>() {}.type) | |
/** | |
* Returns the value of the given config path converted to Java types according to the [type]. Allowable types are based on what the | |
* underlying config library supports and may be one of the following types: | |
* | |
* - Int, Long, Double, String, Boolean | |
* - [Duration], [Period], [TemporalAmount] | |
* - [Path], [URI] | |
* - [ConfigMemorySize] | |
* - [Config], [ConfigObject], [ConfigValue] | |
* - An [Enum] | |
* - Or a `List<T>`, `Set<T>`, or `Map<String, V>` where V is one of the other types. | |
* | |
* URLs are parsed into [URI] objects using [UserInput.url] and thus the rules there apply. | |
*/ | |
@Suppress("UNCHECKED_CAST") | |
public fun <T> Config.read(path: String, type: Type): T? { | |
if (!hasPath(path)) | |
return null | |
// Custom enum reader because we want to be case insensitive. | |
fun enum(path: String, enumClass: Class<Enum<*>>): Enum<*> { | |
val enumConfigValue = getValue(path) | |
if (enumConfigValue.valueType() != ConfigValueType.STRING) | |
throw BadValue(enumConfigValue.origin(), path, "The value at $path must be a string") | |
val value = (enumConfigValue.unwrapped() as String).uppercase() | |
return try { | |
java.lang.Enum.valueOf(enumClass, value) | |
} catch (e: IllegalArgumentException) { | |
val enumNames: MutableList<String> = ArrayList() | |
val enumConstants: Array<Enum<*>> = enumClass.getEnumConstants() | |
for (enumConstant in enumConstants) { | |
enumNames.add(enumConstant.name) | |
} | |
throw BadValue(enumConfigValue.origin(), path, | |
"The enum class ${enumClass.simpleName} has no constant of the name '$value' (should be one of $enumNames)") | |
} | |
} | |
// We deliberately don't support all possible types. This is not a generic serialization library, it's for reading human-oriented | |
// configuration. The types shouldn't get too complicated. | |
fun asList(): List<*> { | |
return when (val innerType = ((type as ParameterizedType).actualTypeArguments[0] as WildcardType).upperBounds[0]) { | |
Integer::class.java -> getIntList(path) | |
Long::class.java -> getLongList(path) | |
Double::class.java -> getDoubleList(path) | |
String::class.java -> getStringList(path) | |
Path::class.java -> getStringList(path).map { Path.of(it) } // (file) Path vs (config key) path | |
URI::class.java -> getStringList(path).map { URI(it) } | |
Boolean::class.java -> getBooleanList(path) | |
Duration::class.java -> getDurationList(path) | |
ConfigMemorySize::class.java -> getMemorySizeList(path) | |
Config::class.java -> getConfigList(path) | |
ConfigObject::class.java -> getObjectList(path) | |
ConfigValue::class.java -> getList(path) | |
else -> { | |
if (innerType is Class<*> && innerType.isEnum) | |
getStringList(path).map { enum(path, innerType as Class<Enum<*>>) } | |
else | |
throw IllegalArgumentException( | |
"List<${(type.actualTypeArguments[0] as WildcardType).upperBounds[0]}> is unsupported: must be Int, Long, String," + | |
" Boolean, Double, Duration, ConfigMemorySize, Config, ConfigObject, Enum" | |
) | |
} | |
} | |
} | |
try { | |
val value: Any = when (type) { | |
Integer::class.java -> getInt(path) | |
Long::class.java -> getLong(path) | |
Double::class.java -> getDouble(path) | |
// Disallow blank strings: these should not be semantically different from missing/null. | |
String::class.java -> getString(path).trimIndent().trim().also { UserInput.verifyThat(it, !isBlank) } | |
Boolean::class.java -> getBoolean(path) | |
Duration::class.java -> getDuration(path) | |
Period::class.java -> getPeriod(path) | |
TemporalAmount::class.java -> getTemporal(path) | |
Path::class.java -> Path.of(getString(path)) // Different kinds of path! | |
URI::class.java -> UserInput.url(getString(path)) | |
ConfigMemorySize::class.java -> getMemorySize(path) | |
Config::class.java -> getConfig(path) | |
ConfigObject::class.java -> getObject(path) | |
ConfigValue::class.java -> getValue(path) | |
is ParameterizedType -> | |
when (type.rawType) { | |
List::class.java -> asList() | |
Set::class.java -> LinkedHashSet(asList()) // Preserve the ordering found in the file. | |
Map::class.java -> { | |
val keyType = type.actualTypeArguments[0] | |
val valueType = (type.actualTypeArguments[1] as WildcardType).upperBounds.single() | |
require(keyType == String::class.java) { "Only Map<String, T> is supported, found: ${type.typeName} " } | |
getObject(path).entries.map { (k, _) -> | |
Pair(k, checkNotNull(read("$path.\"$k\"", valueType))) | |
}.toMap() | |
} | |
else -> throw IllegalArgumentException("Non-convertible parametric type ${type.typeName}") | |
} | |
else -> { | |
if (type is Class<*> && type.isEnum) { | |
enum(path, type as Class<Enum<*>>) | |
} else | |
throw IllegalArgumentException("Non-convertible type ${type.typeName}") | |
} | |
} | |
return value as T | |
} catch (e: ConfigException) { | |
throw e | |
} catch (e: Exception) { | |
// Errors in conversion are always bad values, so ensure the source of the error is tracked. | |
val origin = getValue(path).origin() | |
val msg = e.message ?: "$path failed validation (${e.javaClass.simpleName})" | |
throw BadValue(origin, path, msg, e) | |
} | |
} | |
/** | |
* Allow delegation of a read only property to a config. The name of the property is transformed like this: | |
* | |
* - The name is stripped of any prefix underscores. | |
* - The name is converted from camelCase to kebab-case to meet the standard HOCON style. | |
* | |
* Allowable property are based on what the underlying config library supports and may be one of the following types: | |
* | |
* - Int, Long, Double, String, Boolean | |
* - [Duration], [Period], [TemporalAmount], [Path] | |
* - [Path], [URI] | |
* - [ConfigMemorySize] | |
* - [Config], [ConfigObject], [ConfigValue] | |
* - An [Enum] | |
* - Or a `List<T>`, `Set<T>`, or `Map<String, V>` where V is one of the other types. | |
* | |
* URLs are parsed into [URI] objects using [UserInput.url] and thus the rules there apply. | |
* | |
* @throws ConfigException.Missing if the property isn't nullable and the config is missing that path | |
*/ | |
public inline fun <reified CONFIG_TYPE : Any> Config.req(): PropertyDelegateProvider<Any?, ReadOnlyProperty<Any?, CONFIG_TYPE>> { | |
return PropertyDelegateProvider { _: Any?, property -> | |
val path = property.name.dropWhile { it == '_' }.camelToKebabCase() | |
// Pre-read the config value at delegation time, not access time. This provides more up-front config validation | |
// to avoid crashes half way through an operation. | |
val value: CONFIG_TYPE = read(path) ?: throw ConfigException.Missing(origin(), path) | |
ReadOnlyProperty { _: Any?, _: KProperty<*> -> value } | |
} | |
} | |
/** | |
* Same as [req] except [check] is called with the value before it's returned and any thrown exceptions are rethrown with origin | |
* information as [ConfigException.BadValue]. | |
*/ | |
public inline fun <reified CONFIG_TYPE : Any> Config.reqAndAlso( | |
noinline check: (CONFIG_TYPE) -> Unit | |
): PropertyDelegateProvider<Any?, ReadOnlyProperty<Any?, CONFIG_TYPE>> { | |
return PropertyDelegateProvider { _: Any?, property -> | |
val path = property.name.dropWhile { it == '_' }.camelToKebabCase() | |
// Pre-read the config value at delegation time, not access time. This provides more up-front config validation | |
// to avoid crashes half way through an operation. | |
val value: CONFIG_TYPE = read(path) ?: throw ConfigException.Missing(origin(), path) | |
try { | |
check(value) | |
} catch (e: Exception) { | |
throw BadValue(getValue(path).origin(), path, e.message ?: "$path failed validation (${e.javaClass.simpleName})", e) | |
} | |
ReadOnlyProperty { _: Any?, _: KProperty<*> -> value } | |
} | |
} | |
/** | |
* Same as [req] but with a conversion function. Any exceptions are caught and rethrown as [ConfigException.BadValue] with the origin | |
* of the computed path. The conversion is memo-ized and lazy i.e. it will only be run on first access and the result is then cached. | |
* | |
* @see req | |
*/ | |
public inline fun <reified CONFIG_TYPE : Any, R : Any> Config.reqAndConvert( | |
noinline convert: (CONFIG_TYPE) -> R | |
): PropertyDelegateProvider<Any?, ReadOnlyProperty<Any?, R>> { | |
val type = object : TypeHolder<CONFIG_TYPE>() {}.type | |
return PropertyDelegateProvider { _: Any?, property -> | |
val path = property.name.dropWhile { it == '_' }.camelToKebabCase() | |
// Pre-read the config value at delegation time, not access time. This provides more up-front config validation | |
// to avoid crashes half way through an operation. | |
val value: CONFIG_TYPE = read(path, type) ?: throw ConfigException.Missing(origin(), path) | |
val converted by lazy { | |
try { | |
convert(value) | |
} catch (e: Exception) { | |
throw BadValue( | |
getValue(path).origin(), | |
path, | |
e.message ?: "$path failed validation (${e.javaClass.simpleName})", | |
e | |
) | |
} | |
} | |
ReadOnlyProperty { _: Any?, _: KProperty<*> -> converted } | |
} | |
} | |
/** Same as [req] but returns null if the path is missing instead of throwing. */ | |
public inline fun <reified CONFIG_TYPE : Any> Config.opt(): PropertyDelegateProvider<Any?, ReadOnlyProperty<Any?, CONFIG_TYPE?>> { | |
return PropertyDelegateProvider { _: Any?, property -> | |
val path = property.name.dropWhile { it == '_' }.camelToKebabCase() | |
// Pre-read the config value at delegation time, not access time. This provides more up-front config validation | |
// to avoid crashes half way through an operation. | |
val value = read<CONFIG_TYPE?>(path) | |
ReadOnlyProperty { _: Any?, _: KProperty<*> -> value } | |
} | |
} | |
/** | |
* Same as [opt] except [check] is called with the value before it's returned if present, and any thrown exceptions are rethrown with origin | |
* information as [ConfigException.BadValue]. | |
*/ | |
public inline fun <reified CONFIG_TYPE : Any> Config.optAndAlso( | |
noinline check: (CONFIG_TYPE) -> Unit | |
): PropertyDelegateProvider<Any?, ReadOnlyProperty<Any?, CONFIG_TYPE?>> { | |
return PropertyDelegateProvider { _: Any?, property -> | |
val path = property.name.dropWhile { it == '_' }.camelToKebabCase() | |
val value: CONFIG_TYPE? = read(path) | |
// Pre-read the config value at delegation time, not access time. This provides more up-front config validation | |
// to avoid crashes half way through an operation. | |
try { | |
if (value != null) check(value) | |
} catch (e: Exception) { | |
throw BadValue(getValue(path).origin(), path, e.message ?: "$path failed validation (${e.javaClass.simpleName})", e) | |
} | |
ReadOnlyProperty { _: Any?, _: KProperty<*> -> value } | |
} | |
} | |
/** | |
* Same as [opt] but with a conversion function. Any exceptions are caught and rethrown as [ConfigException.BadValue] with the origin | |
* of the computed path. | |
*/ | |
public inline fun <reified CONFIG_TYPE : Any, R> Config.optAndConvert( | |
noinline convert: (CONFIG_TYPE) -> R | |
): PropertyDelegateProvider<Any?, ReadOnlyProperty<Any?, R?>> { | |
val type = object : TypeHolder<CONFIG_TYPE>() {}.type | |
return PropertyDelegateProvider { _: Any?, property -> | |
val path = property.name.dropWhile { it == '_' }.camelToKebabCase() | |
// Pre-read the config value at delegation time, not access time. This provides more up-front config validation | |
// to avoid crashes half way through an operation. | |
val value: CONFIG_TYPE? = read(path, type) | |
val converted by lazy { | |
try { | |
convert(value!!) | |
} catch (e: Exception) { | |
throw BadValue(getValue(path).origin(), path, e.message ?: "$path failed validation (${e.javaClass.simpleName})", e) | |
} | |
} | |
ReadOnlyProperty { _: Any?, _: KProperty<*> -> if (value == null) null else converted } | |
} | |
} | |
/** | |
* Returns a [Config] object rendered to a set of lines of the form "path = value", with no nesting. | |
*/ | |
public fun Config.renderToFlatProperties(): String = | |
entrySet().map { "${it.key} = ${it.value.render(ConfigRenderOptions.concise())}" }.sorted().joinToString(System.lineSeparator()) | |
/** | |
* Takes the comments associated with this value and appends them to the given [StringBuilder]. | |
*/ | |
public fun ConfigValue.commentsTo(sb: StringBuilder) { | |
for (c in origin().comments()) { | |
sb.append("#") | |
if (c.isNotBlank()) { | |
sb.append(" ") | |
sb.append(c.trim()) | |
} | |
sb.appendLine() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment