Skip to content

Commit

Permalink
Support an idiomatic environment source
Browse files Browse the repository at this point in the history
Resolves #410.
  • Loading branch information
rocketraman committed Mar 29, 2024
1 parent 90662ef commit fafb6a4
Show file tree
Hide file tree
Showing 9 changed files with 312 additions and 6 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 @@ -24,6 +25,7 @@ class EnvironmentVariablesPropertySource(
key
.let { if (prefix == null) it else it.removePrefix(prefix) }
.let { if (useUnderscoresAsSeparator) it.replace("__", ".") else it }
.let { if (useSingleUnderscoresAsSeparator) it.replace("_", ".") else it }
.let {
if (allowUppercaseNames && Character.isUpperCase(it.codePointAt(0))) {
it.split(".").joinToString(separator = ".") { value ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,43 @@ 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"),
map = mapOf("b" to StringNode("bar", Pos.env, DotPath("a", "b"), sourceKey = "a.b")),
pos = Pos.SourcePos("env"),
path = DotPath("a"),
sourceKey = "a"
),
"c" to StringNode("baz", Pos.env, DotPath("c"), sourceKey = "c"),
"d" to MapNode(
value = Undefined,
map = mapOf("e" to StringNode("gaz", Pos.env, DotPath("d", "e"), sourceKey = "d__e")),
pos = Pos.SourcePos("env"),
path = DotPath("d"),
sourceKey = null
),
),
pos = Pos.env,
DotPath.root
)
}

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(
Expand All @@ -24,6 +56,13 @@ class EnvironmentVariablesPropertySourceTest : FunSpec({
sourceKey = "a"
),
"c" to StringNode("baz", Pos.env, DotPath("c"), sourceKey = "c"),
"d" to MapNode(
value = Undefined,
map = mapOf("e" to StringNode("gaz", Pos.env, DotPath("d", "e"), sourceKey = "d_e")),
pos = Pos.SourcePos("env"),
path = DotPath("d"),
sourceKey = null
),
),
pos = Pos.env,
DotPath.root
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,59 @@
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 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()
addNodeTransformer(PathNormalizer)
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 {
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("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")
}
}

})
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.sksamuel.hoplite.json

import com.sksamuel.hoplite.ConfigLoader
import com.sksamuel.hoplite.addIdiomaticEnvironmentSource
import com.sksamuel.hoplite.transformer.PathNormalizer
import io.kotest.core.spec.style.FunSpec
import io.kotest.extensions.system.withEnvironment
import io.kotest.matchers.shouldBe

class EnvPropertySourceSingleUnderscoreAsSeparatorTest : FunSpec({

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

test("loading from envs") {
withEnvironment(mapOf("creds_username" to "a", "creds_password" to "b", "someCamelSetting" to "c")) {
ConfigLoader
.builder()
.addIdiomaticEnvironmentSource()
.build()
.loadConfigOrThrow<Config>() shouldBe Config(Creds("a", "b"), "c")
}
}

test("loading from envs with a path normalizer") {
withEnvironment(mapOf("CREDS_USERNAME" to "a", "CREDS_PASSWORD" to "b", "SOMECAMELSETTING" to "c")) {
ConfigLoader
.builder()
.addNodeTransformer(PathNormalizer)
.addIdiomaticEnvironmentSource()
.build()
.loadConfigOrThrow<Config>() shouldBe Config(Creds("a", "b"), "c")
}
}
})
Loading

0 comments on commit fafb6a4

Please sign in to comment.