Skip to content

Commit 13637fd

Browse files
authored
feat: List expansion from environment variables (#833)
Adds syntax to specify substitution of a list of values provided through separate, indexed environment variables.
1 parent edccc19 commit 13637fd

File tree

8 files changed

+213
-20
lines changed

8 files changed

+213
-20
lines changed

HOCON.md

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,14 @@
2626
- [Self-Referential Substitutions](#self-referential-substitutions)
2727
- [The `+=` field separator](#the--field-separator)
2828
- [Examples of Self-Referential Substitutions](#examples-of-self-referential-substitutions)
29+
- [List values from environment variables](#list-values-from-environment-variables)
2930
- [Includes](#includes)
30-
- [Include syntax](#include-syntax)
31-
- [Include semantics: merging](#include-semantics-merging)
32-
- [Include semantics: substitution](#include-semantics-substitution)
33-
- [Include semantics: missing files and required files](#include-semantics-missing-files-and-required-files)
34-
- [Include semantics: file formats and extensions](#include-semantics-file-formats-and-extensions)
35-
- [Include semantics: locating resources](#include-semantics-locating-resources)
31+
- [Include syntax](#include-syntax)
32+
- [Include semantics: merging](#include-semantics-merging)
33+
- [Include semantics: substitution](#include-semantics-substitution)
34+
- [Include semantics: missing files and required files](#include-semantics-missing-files-and-required-files)
35+
- [Include semantics: file formats and extensions](#include-semantics-file-formats-and-extensions)
36+
- [Include semantics: locating resources](#include-semantics-locating-resources)
3637
- [Conversion of numerically-indexed objects to arrays](#conversion-of-numerically-indexed-objects-to-arrays)
3738
- [MIME Type](#mime-type)
3839
- [API Recommendations](#api-recommendations)
@@ -888,6 +889,33 @@ rather than by the path inside the `${}` expression, because
888889
substitutions may be resolved differently depending on their
889890
position in the file.
890891

892+
893+
#### List values from environment variables
894+
895+
Environment variables are inherently single string values, but
896+
sometimes it's useful to populate a list-valued configuration
897+
setting from the environment. The `[]` suffix on a substitution
898+
enables this:
899+
900+
my-list = ${MY_LIST[]}
901+
902+
This is only supported for environment variables and not system properties or path expressions
903+
904+
When a substitution with the `[]` suffix is resolved via
905+
environment variable fallback, the implementation will look up
906+
`MY_LIST_0`, `MY_LIST_1`, `MY_LIST_2`, and so on (incrementing
907+
the index) until an environment variable is not found. The
908+
collected values form a list.
909+
910+
- If no element is found (i.e. `MY_LIST_0` is not set), a
911+
required substitution `${MY_LIST[]}` is an error, while an
912+
optional substitution `${?MY_LIST[]}` is treated as undefined
913+
(the key is removed from the config).
914+
- Each element value is a string, as with all environment
915+
variables. Automatic type conversion applies when the
916+
application requests another type (e.g. `getIntList()`).
917+
- The separator between the base name and the index is `_`.
918+
891919
### Includes
892920

893921
#### Include syntax

README.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -698,7 +698,22 @@ Using java properties you specify the exact position:
698698

699699
-Ditems.0="a" -Ditems.1="b"
700700

701-
as well as with environment variables:
701+
It is also possible to use environment variables to define a list using the special `[]` suffix to the
702+
environment variable name:
703+
704+
path = [ "a" ]
705+
path = ${?OPTIONAL_A[]}
706+
707+
with the values set as individual environment values using the name and an index suffix:
708+
709+
OPTIONAL_A_1="a"
710+
OPTIONAL_A_2="b"
711+
712+
the index must be gap free, as soon as the next index is not found, the collection of values stops.
713+
714+
This only works for environment variables, not java properties or relative paths.
715+
716+
Finally, it is also possible to use the CONFIG_FORCE feature:
702717

703718
export CONFIG_FORCE_items_0=a
704719
export CONFIG_FORCE_items_1=b

build.sbt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,13 @@ lazy val configLib = Project("config", file("config"))
7777
"CONFIG_FORCE_akka_event__handler__dispatcher_max__pool__size" -> "10",
7878
"SECRET_A" -> "A", // ConfigTest.renderShowEnvVariableValues
7979
"SECRET_B" -> "B", // ConfigTest.renderShowEnvVariableValues
80-
"SECRET_C" -> "C" // ConfigTest.renderShowEnvVariableValues
80+
"SECRET_C" -> "C", // ConfigTest.renderShowEnvVariableValues
81+
"MY_LIST_0" -> "a", // ConfigTest.envVariableListExpansion
82+
"MY_LIST_1" -> "b", // ConfigTest.envVariableListExpansion
83+
"MY_LIST_2" -> "c", // ConfigTest.envVariableListExpansion
84+
"NUM_LIST_0" -> "1", // ConfigTest.envVariableListExpansion
85+
"NUM_LIST_1" -> "2", // ConfigTest.envVariableListExpansion
86+
"NUM_LIST_2" -> "3" // ConfigTest.envVariableListExpansion
8187
),
8288

8389
OsgiKeys.exportPackage := Seq("com.typesafe.config", "com.typesafe.config.impl"),

config/src/main/java/com/typesafe/config/impl/ConfigNodeSimpleValue.java

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,25 @@ else if (Tokens.isUnquotedText(token))
2929
return new ConfigString.Unquoted(token.origin(), Tokens.getUnquotedText(token));
3030
else if (Tokens.isSubstitution(token)) {
3131
List<Token> expression = Tokens.getSubstitutionPathExpression(token);
32-
Path path = PathParser.parsePathExpression(expression.iterator(), token.origin());
3332
boolean optional = Tokens.getSubstitutionOptional(token);
3433

35-
return new ConfigReference(token.origin(), new SubstitutionExpression(path, optional));
34+
// Detect and strip trailing [] for list expansion syntax.
35+
// Inside a substitution, [] is tokenized as OPEN_SQUARE + CLOSE_SQUARE.
36+
boolean listExpansion = false;
37+
List<Token> pathExpression = expression;
38+
int size = expression.size();
39+
if (size >= 2) {
40+
Token secondLast = expression.get(size - 2);
41+
Token last = expression.get(size - 1);
42+
if (secondLast == Tokens.OPEN_SQUARE && last == Tokens.CLOSE_SQUARE) {
43+
listExpansion = true;
44+
pathExpression = expression.subList(0, size - 2);
45+
}
46+
}
47+
48+
Path path = PathParser.parsePathExpression(pathExpression.iterator(), token.origin());
49+
50+
return new ConfigReference(token.origin(), new SubstitutionExpression(path, optional, listExpansion));
3651
}
3752
throw new ConfigException.BugOrBroken("ConfigNodeSimpleValue did not contain a valid value token");
3853
}

config/src/main/java/com/typesafe/config/impl/ResolveSource.java

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package com.typesafe.config.impl;
22

3+
import java.util.ArrayList;
4+
import java.util.List;
5+
36
import com.typesafe.config.ConfigException;
47
import com.typesafe.config.impl.AbstractConfigValue.NotPossibleToResolve;
58

@@ -110,9 +113,19 @@ ResultWithPath lookupSubst(ResolveContext context, SubstitutionExpression subst,
110113
}
111114

112115
if (result.result.value == null && result.result.context.options().getUseSystemEnvironment()) {
113-
if (ConfigImpl.traceSubstitutionsEnabled())
114-
ConfigImpl.trace(result.result.context.depth(), unprefixed + " - looking up in system environment");
115-
result = findInObject(ConfigImpl.envVariablesAsConfigObject(), context, unprefixed);
116+
if (subst.listExpansion()) {
117+
if (ConfigImpl.traceSubstitutionsEnabled())
118+
ConfigImpl.trace(result.result.context.depth(), unprefixed + " - looking up list expansion in system environment");
119+
AbstractConfigValue listValue = expandEnvVarList(ConfigImpl.envVariablesAsConfigObject(), unprefixed, context);
120+
if (listValue != null) {
121+
result = new ResultWithPath(ResolveResult.make(result.result.context, listValue),
122+
new Node<Container>(ConfigImpl.envVariablesAsConfigObject()));
123+
}
124+
} else {
125+
if (ConfigImpl.traceSubstitutionsEnabled())
126+
ConfigImpl.trace(result.result.context.depth(), unprefixed + " - looking up in system environment");
127+
result = findInObject(ConfigImpl.envVariablesAsConfigObject(), context, unprefixed);
128+
}
116129
}
117130
}
118131

@@ -122,6 +135,30 @@ ResultWithPath lookupSubst(ResolveContext context, SubstitutionExpression subst,
122135
return result;
123136
}
124137

138+
private static AbstractConfigValue expandEnvVarList(AbstractConfigObject envObj, Path basePath, ResolveContext context) {
139+
// Build the base env var name from the path (e.g. path "MY_LIST" -> "MY_LIST")
140+
String baseName = basePath.render();
141+
SimpleConfigOrigin origin = SimpleConfigOrigin.newSimple("env list expansion of " + baseName);
142+
143+
// look for _N environment variables until there is no next value
144+
List<AbstractConfigValue> values = new ArrayList<AbstractConfigValue>();
145+
for (int i = 0; ; i++) {
146+
String key = baseName + "_" + i;
147+
Path elementPath = new Path(key);
148+
AbstractConfigValue v = findInObject(envObj, elementPath).value;
149+
if (v == null) {
150+
break;
151+
}
152+
values.add(v);
153+
}
154+
155+
if (values.isEmpty()) {
156+
return null;
157+
}
158+
159+
return new SimpleConfigList(origin, values);
160+
}
161+
125162
ResolveSource pushParent(Container parent) {
126163
if (parent == null)
127164
throw new ConfigException.BugOrBroken("can't push null parent");

config/src/main/java/com/typesafe/config/impl/SubstitutionExpression.java

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,16 @@ final class SubstitutionExpression {
44

55
final private Path path;
66
final private boolean optional;
7+
final private boolean listExpansion;
78

89
SubstitutionExpression(Path path, boolean optional) {
10+
this(path, optional, false);
11+
}
12+
13+
SubstitutionExpression(Path path, boolean optional, boolean listExpansion) {
914
this.path = path;
1015
this.optional = optional;
16+
this.listExpansion = listExpansion;
1117
}
1218

1319
Path path() {
@@ -18,23 +24,35 @@ boolean optional() {
1824
return optional;
1925
}
2026

27+
boolean listExpansion() {
28+
return listExpansion;
29+
}
30+
2131
SubstitutionExpression changePath(Path newPath) {
2232
if (newPath == path)
2333
return this;
2434
else
25-
return new SubstitutionExpression(newPath, optional);
35+
return new SubstitutionExpression(newPath, optional, listExpansion);
36+
}
37+
38+
SubstitutionExpression changeListExpansion(boolean newListExpansion) {
39+
if (newListExpansion == listExpansion)
40+
return this;
41+
else
42+
return new SubstitutionExpression(path, optional, newListExpansion);
2643
}
2744

2845
@Override
2946
public String toString() {
30-
return "${" + (optional ? "?" : "") + path.render() + "}";
47+
return "${" + (optional ? "?" : "") + path.render() + (listExpansion ? "[]" : "") + "}";
3148
}
3249

3350
@Override
3451
public boolean equals(Object other) {
3552
if (other instanceof SubstitutionExpression) {
3653
SubstitutionExpression otherExp = (SubstitutionExpression) other;
37-
return otherExp.path.equals(this.path) && otherExp.optional == this.optional;
54+
return otherExp.path.equals(this.path) && otherExp.optional == this.optional
55+
&& otherExp.listExpansion == this.listExpansion;
3856
} else {
3957
return false;
4058
}
@@ -44,6 +62,7 @@ public boolean equals(Object other) {
4462
public int hashCode() {
4563
int h = 41 * (41 + path.hashCode());
4664
h = 41 * (h + (optional ? 1 : 0));
65+
h = 41 * (h + (listExpansion ? 1 : 0));
4766
return h;
4867
}
4968
}

config/src/test/resources/env-variables.conf

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,15 @@ secret = a
22
secret = ${?SECRET_A}
33
secrets = ["b", "c"]
44
secrets = [${?SECRET_B}, ${?SECRET_C}]
5+
6+
myList = ${MY_LIST[]}
7+
myOptionalList = ${?MY_LIST[]}
8+
myOptionalUndefinedList = ${?UNDEFINED_LIST[]}
9+
numList = ${NUM_LIST[]}
10+
11+
# list concatenation with env var list expansion
12+
prependedList = ["x", "y"] ${?MY_LIST[]}
13+
appendedList = ${?MY_LIST[]} ["x", "y"]
14+
selfAppendedList = ["x"]
15+
selfAppendedList = ${?selfAppendedList} ${?MY_LIST[]}
16+
appendedUndefined = ["x", "y"] ${?UNDEFINED_LIST[]}

config/src/test/scala/com/typesafe/config/impl/ConfigTest.scala

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,16 @@
44
package com.typesafe.config.impl
55

66
import java.math.BigInteger
7-
import java.time.temporal.{ ChronoUnit, TemporalUnit }
8-
7+
import java.time.temporal.{ChronoUnit, TemporalUnit}
8+
import java.util
9+
import java.util.concurrent.TimeUnit
10+
import java.util.concurrent.TimeUnit.{DAYS, HOURS, MICROSECONDS, MILLISECONDS, MINUTES, NANOSECONDS, SECONDS}
911
import org.junit.Assert._
1012
import org.junit._
1113
import com.typesafe.config._
12-
import java.util.concurrent.TimeUnit
1314

1415
import scala.collection.JavaConverters._
1516
import com.typesafe.config.ConfigResolveOptions
16-
import java.util.concurrent.TimeUnit.{ DAYS, HOURS, MICROSECONDS, MILLISECONDS, MINUTES, NANOSECONDS, SECONDS }
1717

1818
class ConfigTest extends TestUtils {
1919

@@ -1223,6 +1223,16 @@ class ConfigTest extends TestUtils {
12231223
| # env variables
12241224
| "<env variable>"
12251225
| ]""".stripMargin))
1226+
assertTrue(rendered1.contains(
1227+
"""| "myList" : [
1228+
| # env variables
1229+
| "<env variable>",
1230+
| # env variables
1231+
| "<env variable>",
1232+
| # env variables
1233+
| "<env variable>"
1234+
| ]""".stripMargin
1235+
))
12261236

12271237
val showRenderOpt = ConfigRenderOptions.defaults()
12281238
val rendered2 = config.root().render(showRenderOpt)
@@ -1236,6 +1246,57 @@ class ConfigTest extends TestUtils {
12361246
| ]""".stripMargin))
12371247
}
12381248

1249+
@Test
1250+
def envVariableListExpansion(): Unit = {
1251+
val config = ConfigFactory.load("env-variables")
1252+
1253+
val myList = config.getStringList("myList")
1254+
assertEquals("basic environment variable list expansion", 3, myList.size())
1255+
assertEquals("a", myList.get(0))
1256+
assertEquals("b", myList.get(1))
1257+
assertEquals("c", myList.get(2))
1258+
1259+
val myOptionalList = config.getStringList("myOptionalList")
1260+
assertEquals("optional environment variable list expansion", 3, myOptionalList.size())
1261+
assertEquals("a", myOptionalList.get(0))
1262+
1263+
assertFalse("optional environment variable list expansion (undefined)", config.hasPath("myOptionalUndefinedList"))
1264+
1265+
val numList = config.getIntList("numList")
1266+
assertEquals("environment variable list conversion", 3, numList.size())
1267+
assertEquals(1, numList.get(0).intValue())
1268+
assertEquals(2, numList.get(1).intValue())
1269+
assertEquals(3, numList.get(2).intValue())
1270+
}
1271+
1272+
@Test
1273+
def envVariableListExpansionAppend(): Unit = {
1274+
val config = ConfigFactory.load("env-variables")
1275+
1276+
// list: ["x", "y"] ${?MY_LIST[]}
1277+
val prepended = config.getStringList("prependedList")
1278+
assertEquals("prepend static values before env var list", util.Arrays.asList("x", "y", "a", "b", "c"), prepended)
1279+
1280+
// list: ${?MY_LIST[]} ["x", "y"]
1281+
val appended = config.getStringList("appendedList")
1282+
assertEquals("append static values after env var list", util.Arrays.asList("a", "b", "c", "x", "y"), appended)
1283+
1284+
// myList = ["x"]; myList = ${?myList} ${?MY_LIST[]}
1285+
val selfAppended = config.getStringList("selfAppendedList")
1286+
assertEquals("self-referential append", util.Arrays.asList("x", "a", "b", "c"), selfAppended)
1287+
1288+
val appendedUndefined = config.getStringList("appendedUndefined")
1289+
assertEquals("appending undefined optional list leaves original intact", util.Arrays.asList("x", "y"), appendedUndefined)
1290+
}
1291+
1292+
@Test
1293+
def envVariableListExpansionRequiredUndefined(): Unit = {
1294+
val e = intercept[ConfigException.UnresolvedSubstitution] {
1295+
ConfigFactory.parseString("foo = ${UNDEFINED_LIST[]}").resolve()
1296+
}
1297+
assertTrue(e.getMessage.contains("UNDEFINED_LIST"))
1298+
}
1299+
12391300
@Test
12401301
def serializeRoundTrip() {
12411302
for (i <- 1 to 10) {

0 commit comments

Comments
 (0)