Skip to content

Commit

Permalink
Support loading configs via prefix
Browse files Browse the repository at this point in the history
  • Loading branch information
rocketraman committed Apr 1, 2024
1 parent 21371c6 commit e1f64a2
Show file tree
Hide file tree
Showing 3 changed files with 197 additions and 16 deletions.
54 changes: 41 additions & 13 deletions hoplite-core/src/main/kotlin/com/sksamuel/hoplite/ConfigLoader.kt
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@ class ConfigLoader(
inline fun <reified A : Any> loadConfigOrThrow(vararg resourceOrFiles: String): A =
loadConfigOrThrow(resourceOrFiles.toList())

/**
* Attempts to load config from the specified resources either on the class path or as files on the
* file system, and returns the successfully created instance A, or throws an error.
*
* This function implements fallback, such that the first resource is scanned first, and the second
* resource is scanned if the first does not contain a given path, and so on.
*/
inline fun <reified A : Any> loadConfigOrThrow(prefix: String? = null): A =
loadConfigOrThrow(emptyList(), prefix = prefix)

/**
* Attempts to load config from the specified resources either on the class path or as files on the
* file system, and returns the successfully created instance A, or throws an error.
Expand All @@ -107,15 +117,16 @@ class ConfigLoader(
inline fun <reified A : Any> loadConfigOrThrow(
resourceOrFiles: List<String>,
classpathResourceLoader: ClasspathResourceLoader = ConfigSource.Companion::class.java.toClasspathResourceLoader(),
): A = loadConfig<A>(resourceOrFiles, classpathResourceLoader).returnOrThrow()
prefix: String? = null,
): A = loadConfig<A>(resourceOrFiles, classpathResourceLoader, prefix).returnOrThrow()

/**
* Attempts to load config from the registered property sources marshalled as an instance of A.
* If any properties are missing, or cannot be converted into the applicable types, then this
* function will throw.
*/
fun <A : Any> loadConfigOrThrow(klass: KClass<A>, inputs: List<ConfigSource>): A =
loadConfig(klass, inputs, emptyList()).returnOrThrow()
fun <A : Any> loadConfigOrThrow(klass: KClass<A>, inputs: List<ConfigSource>, prefix: String? = null): A =
loadConfig(klass, inputs, emptyList(), prefix).returnOrThrow()

/**
* Attempts to load config from the specified resources either on the class path or as files on the
Expand All @@ -128,7 +139,8 @@ class ConfigLoader(
inline fun <reified A : Any> loadConfig(
vararg resourceOrFiles: String,
classpathResourceLoader: ClasspathResourceLoader = ConfigSource.Companion::class.java.toClasspathResourceLoader(),
): ConfigResult<A> = loadConfig(resourceOrFiles.toList(), classpathResourceLoader)
prefix: String? = null,
): ConfigResult<A> = loadConfig(resourceOrFiles.toList(), classpathResourceLoader, prefix)

/**
* Attempts to load config from the specified resources either on the class path or as files on the
Expand All @@ -141,14 +153,28 @@ class ConfigLoader(
inline fun <reified A : Any> loadConfig(
resourceOrFiles: List<String>,
classpathResourceLoader: ClasspathResourceLoader = Companion::class.java.toClasspathResourceLoader(),
): ConfigResult<A> = loadConfig(A::class, emptyList(), resourceOrFiles, classpathResourceLoader)
prefix: String? = null,
): ConfigResult<A> = loadConfig(A::class, emptyList(), resourceOrFiles, prefix, classpathResourceLoader)

/**
* Attempts to load config from the specified resources either on the class path or as files on the
* file system, and returns a [ConfigResult] with either the errors during load, or the successfully
* created instance A.
*
* This function implements fallback, such that the first resource is scanned first, and the second
* resource is scanned if the first does not contain a given path, and so on.
*/
inline fun <reified A : Any> loadConfig(
classpathResourceLoader: ClasspathResourceLoader = ConfigSource.Companion::class.java.toClasspathResourceLoader(),
prefix: String? = null,
): ConfigResult<A> = loadConfig(emptyList(), classpathResourceLoader, prefix)

/**
* Attempts to load config from the registered property sources marshalled as an instance of A.
* If any properties are missing, or cannot be converted into the applicable types, then this
* function will return an invalid [ConfigFailure].
*/
inline fun <reified A : Any> loadConfig(): ConfigResult<A> = loadConfig(A::class, emptyList(), emptyList())
inline fun <reified A : Any> loadConfig(): ConfigResult<A> = loadConfig(A::class, emptyList(), emptyList(), null)

// This is where the actual processing takes place for marshalled config.
// All other loadConfig or loadConfigOrThrow methods ultimately end up in this method.
Expand All @@ -157,6 +183,7 @@ class ConfigLoader(
kclass: KClass<A>,
configSources: List<ConfigSource>,
resourceOrFiles: List<String>,
prefix: String?,
classpathResourceLoader: ClasspathResourceLoader = Companion::class.java.toClasspathResourceLoader()
): ConfigResult<A> {
require(kclass.isData) { "Can only decode into data classes [was ${kclass}]" }
Expand All @@ -165,21 +192,22 @@ class ConfigLoader(
parserRegistry = parserRegistry,
allowEmptyTree = allowEmptyTree,
allowNullOverride = allowNullOverride,
resolveTypesCaseInsensitive = resolveTypesCaseInsensitive,
cascadeMode = cascadeMode,
preprocessors = preprocessors,
preprocessingIterations = preprocessingIterations,
prefix = prefix,
resolvers = resolvers,
decoderRegistry = decoderRegistry,
paramMappers = paramMappers,
flattenArraysToString = flattenArraysToString,
resolveTypesCaseInsensitive = resolveTypesCaseInsensitive,
allowUnresolvedSubstitutions = allowUnresolvedSubstitutions,
secretsPolicy = secretsPolicy,
decodeMode = decodeMode,
useReport = useReport,
obfuscator = obfuscator ?: PrefixObfuscator(3),
reportPrintFn = reportPrintFn,
environment = environment,
resolvers = resolvers,
sealedTypeDiscriminatorField = sealedTypeDiscriminatorField,
contextResolverMode = contextResolverMode,
).decode(kclass, environment, resourceOrFiles, propertySources, configSources)
Expand Down Expand Up @@ -220,14 +248,15 @@ class ConfigLoader(
parserRegistry = parserRegistry,
allowEmptyTree = allowEmptyTree,
allowNullOverride = allowNullOverride,
resolveTypesCaseInsensitive = resolveTypesCaseInsensitive,
cascadeMode = cascadeMode,
preprocessors = preprocessors,
preprocessingIterations = preprocessingIterations,
prefix = null,
resolvers = resolvers,
decoderRegistry = decoderRegistry, // not needed to load nodes
paramMappers = paramMappers,
flattenArraysToString = false, // not used when loading nodes
decoderRegistry = decoderRegistry,
paramMappers = paramMappers, // not needed to load nodes
flattenArraysToString = false,
resolveTypesCaseInsensitive = resolveTypesCaseInsensitive, // not used when loading nodes
allowUnresolvedSubstitutions = allowUnresolvedSubstitutions, // not used when loading nodes
secretsPolicy = null, // not used when loading nodes
decodeMode = DecodeMode.Lenient, // not used when loading nodes
Expand All @@ -250,4 +279,3 @@ class ConfigLoader(

@Deprecated("Moved package. Use com.sksamuel.hoplite.sources.MapPropertySource")
typealias MapPropertySource = com.sksamuel.hoplite.sources.MapPropertySource

Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class ConfigParser(
cascadeMode: CascadeMode,
preprocessors: List<Preprocessor>,
preprocessingIterations: Int,
private val prefix: String?,
private val resolvers: List<Resolver>,
private val decoderRegistry: DecoderRegistry,
private val paramMappers: List<ParameterMapper>,
Expand Down Expand Up @@ -81,10 +82,10 @@ class ConfigParser(
cascader.cascade(nodes).flatMap { node ->
val context = context(node)
preprocessing.preprocess(node, context).flatMap { preprocessed ->
check(preprocessed).flatMap {
check(preprocessed.let { if (prefix == null) it else it.atPath(prefix) }).flatMap {

val decoded = decoding.decode(kclass, preprocessed, decodeMode, context)
val state = createDecodingState(preprocessed, context, secretsPolicy)
val decoded = decoding.decode(kclass, it, decodeMode, context)
val state = createDecodingState(it, context, secretsPolicy)

// always do report regardless of decoder result
if (useReport) {
Expand Down
152 changes: 152 additions & 0 deletions hoplite-core/src/test/kotlin/com/sksamuel/hoplite/PrefixTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package com.sksamuel.hoplite

import io.kotest.core.spec.style.FunSpec
import io.kotest.extensions.system.withEnvironment
import io.kotest.matchers.shouldBe

class PrefixTest : FunSpec() {
init {

test("reads config from string at a given prefix") {
data class TestConfig(val a: String, val b: Int)

val config = ConfigLoaderBuilder.default()
.addPropertySource(
PropertySource.string(
"""
foo.a = A value
foo.b = 42
bar.a = A value bar
bar.b = 45
""".trimIndent(), "props"
)
)
.build()
.loadConfigOrThrow<TestConfig>(prefix = "foo")

config shouldBe TestConfig("A value", 42)
}

test("reads config from input stream at a given prefix") {
data class TestConfig(val a: String, val b: Int)

val stream = """
foo.a = A value
foo.b = 42
bar.a = A value bar
bar.b = 45
""".trimIndent().byteInputStream(Charsets.UTF_8)

val config = ConfigLoaderBuilder.default()
.addPropertySource(PropertySource.stream(stream, "props"))
.build()
.loadConfigOrThrow<TestConfig>(prefix = "foo")

config shouldBe TestConfig("A value", 42)
}

test("reads config from map at a given prefix") {
data class TestConfig(val a: String, val b: Int, val other: List<String>)

val arguments = mapOf(
"foo.a" to "A value",
"foo.b" to "42",
"bar.a" to "A value bar",
"bar.b" to "45",
"foo.other" to listOf("Value1", "Value2"),
"bar.other" to listOf("Value1bar", "Value2bar")
)

val config = ConfigLoaderBuilder.default()
.addPropertySource(PropertySource.map(arguments))
.build()
.loadConfigOrThrow<TestConfig>(prefix = "foo")

config shouldBe TestConfig("A value", 42, listOf("Value1", "Value2"))
}

test("reads config from command line at a given prefix") {
data class TestConfig(val a: String, val b: Int, val other: List<String>)

val arguments = arrayOf(
"--foo.a=A value",
"--foo.b=42",
"--bar.a=A value bar",
"--bar.b=45",
"some other value",
"--foo.other=Value1",
"--foo.other=Value2",
"--bar.other=Value1bar",
"--bar.other=Value2bar",
"--other=Value1o",
"--other=Value2o"
)

val config = ConfigLoaderBuilder.default()
.addPropertySource(PropertySource.commandLine(arguments))
.build()
.loadConfigOrThrow<TestConfig>(prefix = "foo")

config shouldBe TestConfig("A value", 42, listOf("Value1", "Value2"))
}

test("reads from added source before default sources at a given prefix") {
data class TestConfig(val a: String, val b: Int, val other: List<String>)

withEnvironment(mapOf("foo.b" to "91", "foo.other" to "Random13")) {

val arguments = arrayOf(
"--foo.a=A value",
"--foo.b=42",
"--bar.a=A value bar",
"--bar.b=45",
"some other value",
"--foo.other=Value1",
"--foo.other=Value2",
"--bar.other=Value1bar",
"--bar.other=Value2bar",
"--other=Value1o",
"--other=Value2o"
)

val config = ConfigLoaderBuilder.default()
.addPropertySource(PropertySource.commandLine(arguments))
.addDefaultPropertySources()
.addEnvironmentSource()
.build()
.loadConfigOrThrow<TestConfig>(prefix = "foo")

config shouldBe TestConfig("A value", 42, listOf("Value1", "Value2"))
}
}

test("reads from default source before specified at a given prefix") {
data class TestConfig(val a: String, val b: Int, val other: List<String>)

withEnvironment(mapOf("foo.b" to "91", "foo.other" to "Random13")) {
val arguments = arrayOf(
"--foo.a=A value",
"--foo.b=42",
"--bar.a=A value bar",
"--bar.b=45",
"some other value",
"--foo.other=Value1",
"--foo.other=Value2",
"--bar.other=Value1bar",
"--bar.other=Value2bar",
"--other=Value1o",
"--other=Value2o"
)

val config = ConfigLoaderBuilder.default()
.addEnvironmentSource()
.addDefaultPropertySources()
.addPropertySource(PropertySource.commandLine(arguments))
.build()
.loadConfigOrThrow<TestConfig>(prefix = "foo")

config shouldBe TestConfig("A value", 91, listOf("Random13"))
}
}
}
}

0 comments on commit e1f64a2

Please sign in to comment.