Skip to content

Commit

Permalink
Allow two-step parsing and decoding/binding
Browse files Browse the repository at this point in the history
With this approach, if there are multiple prefixes and config classes to
be bound, we parse the configuration only a single time, and decode
multiple times.
  • Loading branch information
rocketraman committed Apr 4, 2024
1 parent f0f6b50 commit d196ce4
Show file tree
Hide file tree
Showing 4 changed files with 160 additions and 89 deletions.
36 changes: 36 additions & 0 deletions hoplite-core/src/main/kotlin/com/sksamuel/hoplite/ConfigBinder.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.sksamuel.hoplite

import com.sksamuel.hoplite.env.Environment
import com.sksamuel.hoplite.internal.ConfigParser
import kotlin.reflect.KClass

/**
* Maintains a parsed configuration which can be used to bind to multiple configuration classes with a prefix.
*/
class ConfigBinder(
@PublishedApi internal val configParser: ConfigParser,
@PublishedApi internal val environment: Environment?,
private val onFailure: List<(Throwable) -> Unit> = emptyList(),
) {
/**
* Binds the configuration to a configuration class, looking at with the given prefix.
*/
inline fun <reified A : Any> bind(prefix: String): ConfigResult<A> = bind(A::class, prefix)

/**
* Binds the configuration to a configuration class, looking at with the given prefix.
*/
inline fun <reified A : Any> bindOrThrow(prefix: String): A = bindOrThrow(A::class, prefix)

/**
* Binds the configuration to a configuration class with the given prefix.
*/
fun <A : Any> bind(type: KClass<A>, prefix: String): ConfigResult<A> =
configParser.decode(type, environment, prefix)

/**
* Binds the configuration to a configuration class with the given prefix, or throws if the result is not valid.
*/
fun <A : Any> bindOrThrow(type: KClass<A>, prefix: String): A =
configParser.decode(type, environment, prefix).returnOrThrow(onFailure)
}
128 changes: 74 additions & 54 deletions hoplite-core/src/main/kotlin/com/sksamuel/hoplite/ConfigLoader.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import com.sksamuel.hoplite.resolver.context.ContextResolverMode
import com.sksamuel.hoplite.secrets.Obfuscator
import com.sksamuel.hoplite.secrets.PrefixObfuscator
import com.sksamuel.hoplite.secrets.SecretsPolicy
import com.sksamuel.hoplite.secrets.StrictObfuscator
import kotlin.reflect.KClass

class ConfigException(msg: String, val t: Throwable? = null) : java.lang.RuntimeException(msg, t)
Expand Down Expand Up @@ -178,6 +177,30 @@ class ConfigLoader(
*/
inline fun <reified A : Any> loadConfig(): ConfigResult<A> = loadConfig(A::class, emptyList(), emptyList(), null)

/**
* Create a [ConfigBinder] which can be used to bind instances of config classes using the same parsed
* configuration.
*/
fun configBinder(
resourceOrFiles: List<String> = emptyList(),
configSources: List<ConfigSource> = emptyList(),
classpathResourceLoader: ClasspathResourceLoader = ConfigLoader::class.java.toClasspathResourceLoader()
): ConfigBinder {
val configParser = createConfigParser(classpathResourceLoader, resourceOrFiles, configSources)
return ConfigBinder(configParser, environment)
}

// This is where the actual processing takes place for marshalled config.
// All other loadConfig or loadConfigOrThrow methods ultimately end up in this method.
fun <A : Any> bindConfig(
parser: ConfigParser,
kclass: KClass<A>,
prefix: String?,
): ConfigResult<A> {
require(kclass.isData) { "Can only decode into data classes [was ${kclass}]" }
return parser.decode(kclass, environment, prefix)
}

// This is where the actual processing takes place for marshalled config.
// All other loadConfig or loadConfigOrThrow methods ultimately end up in this method.
@PublishedApi
Expand All @@ -189,31 +212,12 @@ class ConfigLoader(
classpathResourceLoader: ClasspathResourceLoader = Companion::class.java.toClasspathResourceLoader()
): ConfigResult<A> {
require(kclass.isData) { "Can only decode into data classes [was ${kclass}]" }
return ConfigParser(
val configParser = createConfigParser(
resourceOrFiles = resourceOrFiles,
configSources = configSources,
classpathResourceLoader = classpathResourceLoader,
parserRegistry = parserRegistry,
allowEmptyTree = allowEmptyTree,
allowNullOverride = allowNullOverride,
cascadeMode = cascadeMode,
preprocessors = preprocessors,
preprocessingIterations = preprocessingIterations,
nodeTransformers = nodeTransformers,
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,
sealedTypeDiscriminatorField = sealedTypeDiscriminatorField,
contextResolverMode = contextResolverMode,
).decode(kclass, environment, resourceOrFiles, propertySources, configSources)
)
return configParser.decode(kclass, environment, prefix)
}

/**
Expand Down Expand Up @@ -246,40 +250,56 @@ class ConfigLoader(
configSources: List<ConfigSource> = emptyList(),
classpathResourceLoader: ClasspathResourceLoader = ConfigLoader::class.java.toClasspathResourceLoader()
): ConfigResult<Node> {
return ConfigParser(
val configParser = createConfigParser(
resourceOrFiles = resourceOrFiles,
configSources = configSources,
classpathResourceLoader = classpathResourceLoader,
parserRegistry = parserRegistry,
allowEmptyTree = allowEmptyTree,
allowNullOverride = allowNullOverride,
cascadeMode = cascadeMode,
preprocessors = preprocessors,
preprocessingIterations = preprocessingIterations,
nodeTransformers = nodeTransformers,
prefix = null,
resolvers = resolvers,
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
useReport = false, // not used when loading nodes
obfuscator = StrictObfuscator("*"),
reportPrintFn = reportPrintFn ?: { },
environment = environment,
sealedTypeDiscriminatorField = sealedTypeDiscriminatorField,
contextResolverMode = contextResolverMode,
).load(resourceOrFiles, propertySources, configSources)
)
return configParser.load()
}

@PublishedApi
internal fun <A : Any> ConfigResult<A>.returnOrThrow(): A = this.getOrElse { failure ->
val err = "Error loading config because:\n\n" + failure.description().indent(Constants.indent)
onFailure.forEach { it(ConfigException(err)) }
throw ConfigException(err)
}
internal fun <A : Any> ConfigResult<A>.returnOrThrow(): A = returnOrThrow(onFailure)

private fun createConfigParser(
classpathResourceLoader: ClasspathResourceLoader,
resourceOrFiles: List<String>,
configSources: List<ConfigSource>,
): ConfigParser = ConfigParser(
classpathResourceLoader = classpathResourceLoader,
parserRegistry = parserRegistry,
allowEmptyTree = allowEmptyTree,
allowNullOverride = allowNullOverride,
cascadeMode = cascadeMode,
preprocessors = preprocessors,
preprocessingIterations = preprocessingIterations,
nodeTransformers = nodeTransformers,
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,
sealedTypeDiscriminatorField = sealedTypeDiscriminatorField,
contextResolverMode = contextResolverMode,
resourceOrFiles = resourceOrFiles,
propertySources = propertySources,
configSources = configSources,
)
}

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

@PublishedApi
internal fun <A : Any> ConfigResult<A>.returnOrThrow(onFailure: List<(Throwable) -> Unit>): A = this.getOrElse { failure ->
val err = "Error loading config because:\n\n" + failure.description().indent(Constants.indent)
onFailure.forEach { it(ConfigException(err)) }
throw ConfigException(err)
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ class ConfigParser(
preprocessors: List<Preprocessor>,
preprocessingIterations: Int,
private val nodeTransformers: List<NodeTransformer>,
private val prefix: String?,
private val resolvers: List<Resolver>,
private val decoderRegistry: DecoderRegistry,
private val paramMappers: List<ParameterMapper>,
Expand All @@ -51,12 +50,21 @@ class ConfigParser(
private val environment: Environment?,
private val sealedTypeDiscriminatorField: String?,
private val contextResolverMode: ContextResolverMode,
private val resourceOrFiles: List<String>,
private val propertySources: List<PropertySource>,
private val configSources: List<ConfigSource>,
) {

private val loader = PropertySourceLoader(nodeTransformers, sealedTypeDiscriminatorField, classpathResourceLoader, parserRegistry, allowEmptyTree)
private val cascader = Cascader(cascadeMode, allowEmptyTree, allowNullOverride)
private val preprocessing = Preprocessing(preprocessors, preprocessingIterations)
private val decoding = Decoding(decoderRegistry, secretsPolicy)
private lateinit var configResult: ConfigResult<Node>
private lateinit var context: DecoderContext

init {
loadResultAndContext()
}

private fun context(root: Node): DecoderContext {
return DecoderContext(
Expand All @@ -73,47 +81,51 @@ class ConfigParser(
fun <A : Any> decode(
kclass: KClass<A>,
environment: Environment?,
resourceOrFiles: List<String>,
propertySources: List<PropertySource>,
configSources: List<ConfigSource>,
prefix: String? = null,
): ConfigResult<A> {

if (decoderRegistry.size == 0)
return ConfigFailure.EmptyDecoderRegistry.invalid()

return loader.loadNodes(propertySources, configSources, resourceOrFiles).flatMap { nodes ->
cascader.cascade(nodes).flatMap { node ->
val context = context(node)
preprocessing.preprocess(node, context).flatMap { preprocessed ->
check(preprocessed.prefixedNode()).flatMap {
val decoded = decoding.decode(kclass, it, decodeMode, context)
val state = createDecodingState(it, context, secretsPolicy)
if (configResult.isInvalid()) {
return configResult.getInvalidUnsafe().invalid()
}

// always do report regardless of decoder result
if (useReport) {
Reporter(reportPrintFn, obfuscator, environment)
.printReport(propertySources, state, context.reporter.getReport())
}
return configResult
.map { it.prefixedNode(prefix) }
.flatMap {
val decoded = decoding.decode(kclass, it, decodeMode, context)
val state = createDecodingState(it, context, secretsPolicy)

decoded
}
// always do report regardless of decoder result
if (useReport) {
Reporter(reportPrintFn, obfuscator, environment, prefix)
.printReport(propertySources, state, context.reporter.getReport())
}

decoded
}
}
}

fun load(
resourceOrFiles: List<String>,
propertySources: List<PropertySource>,
configSources: List<ConfigSource>,
): ConfigResult<Node> {
return loader.loadNodes(propertySources, configSources, resourceOrFiles).flatMap { nodes ->
fun load(): ConfigResult<Node> = configResult

private fun loadResultAndContext() {
if (decoderRegistry.size == 0) {
configResult = ConfigFailure.EmptyDecoderRegistry.invalid()
return
}

loader.loadNodes(propertySources, configSources, resourceOrFiles).flatMap { nodes ->
cascader.cascade(nodes).flatMap { node ->
val context = context(node)
preprocessing.preprocess(node, context).flatMap { preprocessed ->
if (allowUnresolvedSubstitutions) preprocessed.valid() else UnresolvedSubstitutionChecker.process(preprocessed)
check(preprocessed)
}.map {
it to context
}
}
}.onSuccess { result ->
configResult = result.first.valid()
context = result.second
}.onFailure { result ->
configResult = result.invalid()
}
}

Expand All @@ -125,7 +137,7 @@ class ConfigParser(
UnresolvedSubstitutionChecker.process(node)
}

private fun Node.prefixedNode() = when {
private fun Node.prefixedNode(prefix: String?) = when {
prefix == null -> this
nodeTransformers.contains(PathNormalizer) -> atPath(PathNormalizer.normalizePathElement(prefix))
else -> atPath(prefix)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,16 @@ class ReporterBuilder {
@Deprecated("Specify secretsPolicy through ConfigBuilderLoader", level = DeprecationLevel.ERROR)
fun withSecretsPolicy(secretsPolicy: SecretsPolicy): ReporterBuilder = TODO("Unsupported")

fun build(): Reporter = Reporter(print, obfuscator, null)
fun build(): Reporter = Reporter(print, obfuscator, null, null)
}

typealias Print = (String) -> Unit

class Reporter(
private val print: Print,
private val obfuscator: Obfuscator,
private val environment: Environment?
private val environment: Environment?,
private val prefix: String?
) {

object Titles {
Expand All @@ -60,10 +61,12 @@ class Reporter(

val r = buildString {
appendLine()
appendLine("--Start Hoplite Config Report---")
appendLine()
environment?.let { appendLine("Environment: ${it.name}") }
appendLine("--Start Hoplite Config Report${if (prefix != null) " @ Prefix $prefix" else ""}---")
appendLine()
environment?.let {
appendLine("Environment: ${it.name}")
appendLine()
}
appendLine(report(sources))
appendLine()

Expand Down

0 comments on commit d196ce4

Please sign in to comment.