From e1f64a2a9faaa51aaf25ef9ea4f79d8824e70f65 Mon Sep 17 00:00:00 2001 From: Raman Gupta Date: Thu, 28 Mar 2024 13:49:33 -0400 Subject: [PATCH] Support loading configs via prefix --- .../com/sksamuel/hoplite/ConfigLoader.kt | 54 +++++-- .../sksamuel/hoplite/internal/ConfigParser.kt | 7 +- .../kotlin/com/sksamuel/hoplite/PrefixTest.kt | 152 ++++++++++++++++++ 3 files changed, 197 insertions(+), 16 deletions(-) create mode 100644 hoplite-core/src/test/kotlin/com/sksamuel/hoplite/PrefixTest.kt diff --git a/hoplite-core/src/main/kotlin/com/sksamuel/hoplite/ConfigLoader.kt b/hoplite-core/src/main/kotlin/com/sksamuel/hoplite/ConfigLoader.kt index d789fbf3..b0b5a1f9 100644 --- a/hoplite-core/src/main/kotlin/com/sksamuel/hoplite/ConfigLoader.kt +++ b/hoplite-core/src/main/kotlin/com/sksamuel/hoplite/ConfigLoader.kt @@ -97,6 +97,16 @@ class ConfigLoader( inline fun 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 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. @@ -107,15 +117,16 @@ class ConfigLoader( inline fun loadConfigOrThrow( resourceOrFiles: List, classpathResourceLoader: ClasspathResourceLoader = ConfigSource.Companion::class.java.toClasspathResourceLoader(), - ): A = loadConfig(resourceOrFiles, classpathResourceLoader).returnOrThrow() + prefix: String? = null, + ): A = loadConfig(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 loadConfigOrThrow(klass: KClass, inputs: List): A = - loadConfig(klass, inputs, emptyList()).returnOrThrow() + fun loadConfigOrThrow(klass: KClass, inputs: List, 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 @@ -128,7 +139,8 @@ class ConfigLoader( inline fun loadConfig( vararg resourceOrFiles: String, classpathResourceLoader: ClasspathResourceLoader = ConfigSource.Companion::class.java.toClasspathResourceLoader(), - ): ConfigResult = loadConfig(resourceOrFiles.toList(), classpathResourceLoader) + prefix: String? = null, + ): ConfigResult = loadConfig(resourceOrFiles.toList(), classpathResourceLoader, prefix) /** * Attempts to load config from the specified resources either on the class path or as files on the @@ -141,14 +153,28 @@ class ConfigLoader( inline fun loadConfig( resourceOrFiles: List, classpathResourceLoader: ClasspathResourceLoader = Companion::class.java.toClasspathResourceLoader(), - ): ConfigResult = loadConfig(A::class, emptyList(), resourceOrFiles, classpathResourceLoader) + prefix: String? = null, + ): ConfigResult = 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 loadConfig( + classpathResourceLoader: ClasspathResourceLoader = ConfigSource.Companion::class.java.toClasspathResourceLoader(), + prefix: String? = null, + ): ConfigResult = 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 loadConfig(): ConfigResult = loadConfig(A::class, emptyList(), emptyList()) + inline fun loadConfig(): ConfigResult = 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. @@ -157,6 +183,7 @@ class ConfigLoader( kclass: KClass, configSources: List, resourceOrFiles: List, + prefix: String?, classpathResourceLoader: ClasspathResourceLoader = Companion::class.java.toClasspathResourceLoader() ): ConfigResult { require(kclass.isData) { "Can only decode into data classes [was ${kclass}]" } @@ -165,13 +192,15 @@ 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, @@ -179,7 +208,6 @@ class ConfigLoader( obfuscator = obfuscator ?: PrefixObfuscator(3), reportPrintFn = reportPrintFn, environment = environment, - resolvers = resolvers, sealedTypeDiscriminatorField = sealedTypeDiscriminatorField, contextResolverMode = contextResolverMode, ).decode(kclass, environment, resourceOrFiles, propertySources, configSources) @@ -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 @@ -250,4 +279,3 @@ class ConfigLoader( @Deprecated("Moved package. Use com.sksamuel.hoplite.sources.MapPropertySource") typealias MapPropertySource = com.sksamuel.hoplite.sources.MapPropertySource - diff --git a/hoplite-core/src/main/kotlin/com/sksamuel/hoplite/internal/ConfigParser.kt b/hoplite-core/src/main/kotlin/com/sksamuel/hoplite/internal/ConfigParser.kt index ff5c65a0..ba66ab04 100644 --- a/hoplite-core/src/main/kotlin/com/sksamuel/hoplite/internal/ConfigParser.kt +++ b/hoplite-core/src/main/kotlin/com/sksamuel/hoplite/internal/ConfigParser.kt @@ -33,6 +33,7 @@ class ConfigParser( cascadeMode: CascadeMode, preprocessors: List, preprocessingIterations: Int, + private val prefix: String?, private val resolvers: List, private val decoderRegistry: DecoderRegistry, private val paramMappers: List, @@ -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) { diff --git a/hoplite-core/src/test/kotlin/com/sksamuel/hoplite/PrefixTest.kt b/hoplite-core/src/test/kotlin/com/sksamuel/hoplite/PrefixTest.kt new file mode 100644 index 00000000..e9e05090 --- /dev/null +++ b/hoplite-core/src/test/kotlin/com/sksamuel/hoplite/PrefixTest.kt @@ -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(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(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) + + 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(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) + + 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(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) + + 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(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) + + 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(prefix = "foo") + + config shouldBe TestConfig("A value", 91, listOf("Random13")) + } + } + } +}