Skip to content

Commit

Permalink
Support an idiomatic environment source
Browse files Browse the repository at this point in the history
Resolves sksamuel#410.
  • Loading branch information
rocketraman committed Nov 28, 2024
1 parent 93a0227 commit c0b8fed
Show file tree
Hide file tree
Showing 11 changed files with 332 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,32 @@ fun ConfigLoaderBuilder.addCommandLineSource(
*
* @param useUnderscoresAsSeparator if true, use double underscore instead of period to separate keys in nested config
* @param allowUppercaseNames if true, allow uppercase-only names
* @param useSingleUnderscoresAsSeparator if true, allows single underscores as separators, to conform with
* idiomatic environment variable names
*/
fun ConfigLoaderBuilder.addEnvironmentSource(
useUnderscoresAsSeparator: Boolean = true,
allowUppercaseNames: Boolean = true,
useSingleUnderscoresAsSeparator: Boolean = false,
) = addPropertySource(
EnvironmentVariablesPropertySource(useUnderscoresAsSeparator, allowUppercaseNames)
EnvironmentVariablesPropertySource(useUnderscoresAsSeparator, useSingleUnderscoresAsSeparator, allowUppercaseNames)
)

/**
* Adds a [PropertySource] that will read the environment settings.
*
* With this source, environment variables are expected to be idiomatic i.e. uppercase, with underscores as
* separators for path elements. Dashes are removed.
*
* Generally a [PathNormalizer] should be added to the [ConfigLoaderBuilder] to normalize paths when this source
* is used.
*/
fun ConfigLoaderBuilder.addIdiomaticEnvironmentSource() = addPropertySource(
EnvironmentVariablesPropertySource(
useUnderscoresAsSeparator = false,
useSingleUnderscoresAsSeparator = true,
allowUppercaseNames = false
)
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,32 @@ interface PropertySource {
CommandLinePropertySource(arguments, prefix, delimiter)

/**
* Returns a [PropertySource] that will read the environment settings.
* Returns a [PropertySource] that will read the environment settings, by default with the classic
* parsing mechanism using double-underscore as a path separator, and converting uppercase names with
* underscores to camel case.
*/
fun environment(useUnderscoresAsSeparator: Boolean = true, allowUppercaseNames: Boolean = true) =
EnvironmentVariablesPropertySource(useUnderscoresAsSeparator, allowUppercaseNames)
fun environment(
useUnderscoresAsSeparator: Boolean = true,
allowUppercaseNames: Boolean = true,
useSingleUnderscoresAsSeparator: Boolean = false,
) =
EnvironmentVariablesPropertySource(
useUnderscoresAsSeparator,
useSingleUnderscoresAsSeparator,
allowUppercaseNames
)

/**
* Returns a [PropertySource] that will read the environment settings, supporting idiomatic environment
* names. Underscores are used as path separators, and "-" are removed/ignored. We recommend this be used
* along with a [PathNormalizer].
*/
fun idiomaticEnvironment() =
EnvironmentVariablesPropertySource(
useUnderscoresAsSeparator = false,
useSingleUnderscoresAsSeparator = true,
allowUppercaseNames = false
)

/**
* Returns a [PropertySource] that will read from the specified string.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.sksamuel.hoplite.parsers.toNode

class EnvironmentVariablesPropertySource(
private val useUnderscoresAsSeparator: Boolean,
private val useSingleUnderscoresAsSeparator: Boolean,
private val allowUppercaseNames: Boolean,
private val environmentVariableMap: () -> Map<String, String> = { System.getenv() },
private val prefix: String? = null, // optional prefix to strip from the vars
Expand All @@ -21,7 +22,7 @@ class EnvironmentVariablesPropertySource(
.mapKeys { if (prefix == null) it.key else it.key.removePrefix(prefix) }

// at the moment the delimiter is either `__` or `.` -- it can't be mixed
val delimiter = if (useUnderscoresAsSeparator) "__" else "."
val delimiter = if (useUnderscoresAsSeparator) "__" else if (useSingleUnderscoresAsSeparator) "_" else "."

return map.toNode("env", delimiter) { key ->
key
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class CascadingNormalizationTest : FunSpec() {
EnvironmentVariablesPropertySource(
useUnderscoresAsSeparator = false,
allowUppercaseNames = false,
useSingleUnderscoresAsSeparator = false,
environmentVariableMap = { mapOf("section.subSection.someValue" to "3") }
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,71 @@ import io.kotest.matchers.shouldBe

class EnvironmentVariablesPropertySourceTest : FunSpec({

test("build env source should include paths") {
test("build env source with the classic env var mapping should include paths") {
EnvironmentVariablesPropertySource(
useUnderscoresAsSeparator = true,
useSingleUnderscoresAsSeparator = false,
allowUppercaseNames = false,
environmentVariableMap = { mapOf("a" to "foo", "a__b" to "bar", "c" to "baz", "d__e" to "gaz") }
).node(
PropertySourceContext.empty
).getUnsafe() shouldBe MapNode(
mapOf(
"a" to MapNode(
value = StringNode("foo", Pos.env, DotPath("a"), sourceKey = "a", delimiter = "__"),
map = mapOf("b" to StringNode("bar", Pos.env, DotPath("a", "b"), sourceKey = "a__b", delimiter = "__")),
pos = Pos.SourcePos("env"),
path = DotPath("a"),
delimiter = "__",
sourceKey = "a"
),
"c" to StringNode("baz", Pos.env, DotPath("c"), sourceKey = "c", delimiter = "__"),
"d" to MapNode(
value = Undefined,
map = mapOf("e" to StringNode("gaz", Pos.env, DotPath("d", "e"), sourceKey = "d__e", delimiter = "__")),
pos = Pos.SourcePos("env"),
path = DotPath("d"),
delimiter = "__",
sourceKey = "d"
),
),
pos = Pos.env,
path = DotPath.root,
delimiter = "__",
)
}

test("build env source with the idiomatic env var mapping should include paths") {
EnvironmentVariablesPropertySource(
useUnderscoresAsSeparator = false,
useSingleUnderscoresAsSeparator = true,
allowUppercaseNames = false,
environmentVariableMap = { mapOf("a" to "foo", "a.b" to "bar", "c" to "baz") }
environmentVariableMap = { mapOf("a" to "foo", "a_b" to "bar", "c" to "baz", "d_e" to "gaz") }
).node(
PropertySourceContext.empty
).getUnsafe() shouldBe MapNode(
mapOf(
"a" to MapNode(
value = StringNode("foo", Pos.env, DotPath("a"), sourceKey = "a"),
map = mapOf("b" to StringNode("bar", Pos.env, DotPath("a", "b"), sourceKey = "a.b")),
value = StringNode("foo", Pos.env, DotPath("a"), sourceKey = "a", delimiter = "_"),
map = mapOf("b" to StringNode("bar", Pos.env, DotPath("a", "b"), sourceKey = "a_b", delimiter = "_")),
pos = Pos.SourcePos("env"),
path = DotPath("a"),
delimiter = "_",
sourceKey = "a"
),
"c" to StringNode("baz", Pos.env, DotPath("c"), sourceKey = "c"),
"c" to StringNode("baz", Pos.env, DotPath("c"), sourceKey = "c", delimiter = "_"),
"d" to MapNode(
value = Undefined,
map = mapOf("e" to StringNode("gaz", Pos.env, DotPath("d", "e"), sourceKey = "d_e", delimiter = "_")),
pos = Pos.SourcePos("env"),
path = DotPath("d"),
delimiter = "_",
sourceKey = "d"
),
),
pos = Pos.env,
DotPath.root
path = DotPath.root,
delimiter = "_",
)
}

Expand All @@ -37,6 +82,7 @@ class EnvironmentVariablesPropertySourceTest : FunSpec({
addPropertySource(
EnvironmentVariablesPropertySource(
useUnderscoresAsSeparator = false,
useSingleUnderscoresAsSeparator = false,
allowUppercaseNames = true,
environmentVariableMap = {
mapOf("FOO_BAR" to "fooValue")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class PathNormalizerTest : FunSpec({
val node = EnvironmentVariablesPropertySource(
useUnderscoresAsSeparator = false,
allowUppercaseNames = false,
useSingleUnderscoresAsSeparator = false,
environmentVariableMap = { mapOf("A" to "a", "A.B" to "ab", "A.B.CD" to "abcd") },
).node(PropertySourceContext.empty).getUnsafe()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.sksamuel.hoplite.json

import com.sksamuel.hoplite.ConfigLoader
import com.sksamuel.hoplite.ConfigLoaderBuilder
import com.sksamuel.hoplite.sources.EnvironmentVariablesPropertySource
import com.sksamuel.hoplite.transformer.PathNormalizer
import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.shouldBe

class AmbiguousPropertyNameTest : DescribeSpec({

data class Ambiguous(val someCamelSetting: String, val somecamelsetting: String)

describe("loading property differing in case from envs") {
it("with path normalizer cannot disambiguate") {
run {
ConfigLoader {
withReport()
addPropertySource(
EnvironmentVariablesPropertySource(
useUnderscoresAsSeparator = true,
useSingleUnderscoresAsSeparator = false,
allowUppercaseNames = false,
environmentVariableMap = {
mapOf(
"someCamelSetting" to "a",
"somecamelsetting" to "b",
"SOMECAMELSETTING" to "c",
)
}
)
)
}.loadConfigOrThrow<Ambiguous>()
} shouldBe Ambiguous("c", "c")
}

it("without path normalizer") {
run {
ConfigLoaderBuilder.empty()
.addDefaultDecoders()
.addDefaultResolvers()
.addDefaultParamMappers()
.addDefaultPropertySources()
.addDefaultParsers()
.withReport()
.addPropertySource(
EnvironmentVariablesPropertySource(
useUnderscoresAsSeparator = true,
useSingleUnderscoresAsSeparator = false,
allowUppercaseNames = false,
environmentVariableMap = {
mapOf(
"someCamelSetting" to "a",
"somecamelsetting" to "b",
"SOMECAMELSETTING" to "c",
)
}
)
)
.build()
.loadConfigOrThrow<Ambiguous>()
} shouldBe Ambiguous("a", "b")
}
}
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package com.sksamuel.hoplite.json

import com.sksamuel.hoplite.ConfigLoader
import com.sksamuel.hoplite.sources.EnvironmentVariablesPropertySource
import com.sksamuel.hoplite.transformer.PathNormalizer
import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.shouldBe

class EnvPropertySourceNormalizationTest : DescribeSpec({

data class Creds(val username: String, val password: String)
data class Config(val creds: Creds, val someCamelSetting: String)

describe("loading from envs") {
it("with path normalizer") {
run {
ConfigLoader {
addNodeTransformer(PathNormalizer)
addPropertySource(
EnvironmentVariablesPropertySource(
useUnderscoresAsSeparator = true,
useSingleUnderscoresAsSeparator = false,
allowUppercaseNames = false,
environmentVariableMap = {
mapOf(
"CREDS__USERNAME" to "a",
"CREDS__PASSWORD" to "c",
"SOMECAMELSETTING" to "c"
)
}
)
)
}.loadConfigOrThrow<Config>()
} shouldBe Config(Creds("a", "c"), "c")
}

it("with path normalizer and kebab case underscore separator") {
run {
ConfigLoader {
addNodeTransformer(PathNormalizer)
addPropertySource(
EnvironmentVariablesPropertySource(
useUnderscoresAsSeparator = true,
useSingleUnderscoresAsSeparator = false,
allowUppercaseNames = false,
environmentVariableMap = {
mapOf(
"CREDS__USERNAME" to "a",
"CREDS__PASSWORD" to "c",
"SOME_CAMEL_SETTING" to "c"
)
}
)
)
}.loadConfigOrThrow<Config>()
} shouldBe Config(Creds("a", "c"), "c")
}

it("with path normalizer and underscore separator") {
run {
ConfigLoader {
addNodeTransformer(PathNormalizer)
addPropertySource(
EnvironmentVariablesPropertySource(
useUnderscoresAsSeparator = false,
useSingleUnderscoresAsSeparator = true,
allowUppercaseNames = true,
environmentVariableMap = {
mapOf(
"CREDS_USERNAME" to "a",
"CREDS_PASSWORD" to "b",
"SOMECAMELSETTING" to "c"
)
}
)
)
}.loadConfigOrThrow<Config>()
} shouldBe Config(Creds("a", "b"), "c")
}

it("with path normalizer and lowercase names") {
run {
ConfigLoader {
addNodeTransformer(PathNormalizer)
addPropertySource(EnvironmentVariablesPropertySource(
useUnderscoresAsSeparator = false,
useSingleUnderscoresAsSeparator = true,
allowUppercaseNames = false,
environmentVariableMap = {
mapOf(
"creds_username" to "a",
"creds_password" to "d",
"somecamelsetting" to "e"
)
}
))
}.loadConfigOrThrow<Config>()
} shouldBe Config(Creds("a", "d"), "e")
}

it("with path normalizer and prefix") {
run {
ConfigLoader {
addNodeTransformer(PathNormalizer)
addPropertySource(
EnvironmentVariablesPropertySource(
useUnderscoresAsSeparator = false,
useSingleUnderscoresAsSeparator = true,
allowUppercaseNames = false,
environmentVariableMap = {
mapOf(
"WIBBLE_CREDS_USERNAME" to "a",
"WIBBLE_CREDS_PASSWORD" to "c",
"WIBBLE_SOMECAMELSETTING" to "c"
)
},
prefix = "WIBBLE_"
)
)
}.loadConfigOrThrow<Config>()
} shouldBe Config(Creds("a", "c"), "c")
}
}

})
Loading

0 comments on commit c0b8fed

Please sign in to comment.