Skip to content

Instantly share code, notes, and snippets.

@mikehearn
Created May 12, 2021 06:59
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mikehearn/6fb28522265da653d7c67963866136b3 to your computer and use it in GitHub Desktop.
Save mikehearn/6fb28522265da653d7c67963866136b3 to your computer and use it in GitHub Desktop.
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