This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/master by this push:
new 75aff0e9fe Add config profiles and relaxed binding to the config system
75aff0e9fe is described below
commit 75aff0e9fe7677fc715895944b1c4ea6db3abe32
Author: James Bognar <[email protected]>
AuthorDate: Thu Jun 18 13:46:18 2026 -0400
Add config profiles and relaxed binding to the config system
---
.../commons/settings/RelaxedPropertySource.java | 139 +++++++++++++
.../apache/juneau/commons/settings/Settings.java | 15 +-
.../settings/RelaxedPropertySource_Test.java | 124 +++++++++++
.../main/java/org/apache/juneau/config/Config.java | 48 ++++-
.../juneau/config/internal/ProfileMerge.java | 89 ++++++++
.../juneau/config/store/ProfileConfigStore.java | 231 +++++++++++++++++++++
.../apache/juneau/config/ConfigProfiles_Test.java | 154 ++++++++++++++
.../config/store/ProfileConfigStore_Test.java | 156 ++++++++++++++
.../SpringEnvironmentPropertySource.java | 16 +-
.../SpringEnvironmentPropertySource_Test.java | 27 +++
10 files changed, 996 insertions(+), 3 deletions(-)
diff --git
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/settings/RelaxedPropertySource.java
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/settings/RelaxedPropertySource.java
new file mode 100644
index 0000000000..e7100904ee
--- /dev/null
+++
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/settings/RelaxedPropertySource.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.commons.settings;
+
+import java.util.*;
+
+/**
+ * A {@link PropertySource} decorator that adds <b>relaxed binding</b> over a
delegate source.
+ *
+ * <p>
+ * When a property lookup misses against the delegate, this source retries
with a small set of
+ * <i>canonical name variants</i> derived from the requested name, so a single
logical key can be supplied in any of
+ * the common environment conventions and still resolve. This is the
mechanism that lets a Kubernetes-style
+ * environment variable such as {@code MY_SECTION_MY_KEY} satisfy a lookup for
{@code MySection/myKey}, or
+ * {@code MY_PROP} satisfy {@code my.prop} / {@code myProp}.
+ *
+ * <h5 class='section'>Candidate order</h5>
+ * <p>
+ * The requested name is always tried <b>verbatim first</b>, so exact-match
behavior is preserved unchanged (an
+ * already-canonical key resolves with no transformation, and a source that
defines the literal name wins). Only on a
+ * miss are the relaxed variants tried, in this order:
+ * <ol>
+ * <li>verbatim (the requested name, unchanged);
+ * <li><b>upper underscore</b> — every run of non-alphanumeric
characters (and every lower→upper camel-case
+ * boundary) becomes a single {@code _}, then the whole thing is
upper-cased. This is the dominant env-var form:
+ * {@code my.prop}/{@code my-prop}/{@code myProp}/{@code
MySection/myKey} → {@code MY_PROP} /
+ * {@code MY_SECTION_MY_KEY};
+ * <li><b>lower dotted</b> — the same separator collapse but joined
with {@code .} and lower-cased
+ * ({@code MY_PROP} → {@code my.prop}), so a dotted-property
lookup can be satisfied by an env-style key and
+ * vice-versa.
+ * </ol>
+ *
+ * <p>
+ * Because the boundary between "section" and "key" in a flat relaxed name
(e.g. the underscores in
+ * {@code MY_SECTION_MY_KEY}) is inherently ambiguous, this decorator
deliberately works in the
+ * <b>lookup-key → candidate-name</b> direction only (the well-defined
direction): the caller asks for a known
+ * logical key and the decorator generates the environment-convention
spellings to probe. It never tries to reverse a
+ * flat env name back into a {@code section/key} pair.
+ *
+ * <h5 class='section'>Usage</h5>
+ * <p class='bjava'>
+ * <jc>// Wrap the system-env source so env vars bind relaxedly.</jc>
+ * <jv>settings</jv> = Settings.<jsm>create</jsm>().addSource(<jk>new</jk>
RelaxedPropertySource(Settings.<jsf>SYSTEM_ENV_SOURCE</jsf>)).build();
+ * </p>
+ *
+ * @since 10.0.0
+ */
+public class RelaxedPropertySource implements PropertySource {
+
+ private final PropertySource delegate;
+
+ /**
+ * Constructor.
+ *
+ * @param delegate The wrapped source that the generated candidate
names are probed against. Must not be <jk>null</jk>.
+ */
+ public RelaxedPropertySource(PropertySource delegate) {
+ this.delegate = Objects.requireNonNull(delegate, "delegate");
+ }
+
+ @Override
+ public PropertyLookupResult get(String name) {
+ if (name == null)
+ return PropertyLookupResult.missing();
+ for (var candidate : candidates(name)) {
+ var r = delegate.get(candidate);
+ if (r.isPresent())
+ return r;
+ }
+ return PropertyLookupResult.missing();
+ }
+
+ /**
+ * Generates the ordered, de-duplicated list of candidate names for a
requested name (verbatim first).
+ *
+ * @param name The requested property name.
+ * @return The candidate names to probe, in priority order.
+ */
+ static List<String> candidates(String name) {
+ var out = new ArrayList<String>(3);
+ out.add(name);
+ var tokens = tokenize(name);
+ if (! tokens.isEmpty()) {
+ addIfNew(out, String.join("_",
tokens).toUpperCase(Locale.ROOT));
+ addIfNew(out, String.join(".",
tokens).toLowerCase(Locale.ROOT));
+ }
+ return out;
+ }
+
+ /**
+ * Splits a name into lowercase-normalized word tokens, breaking on any
non-alphanumeric run and on each
+ * lower→upper camel-case boundary. E.g. {@code
"MySection/myKey"} → {@code [my, section, my, key]}.
+ */
+ private static List<String> tokenize(String name) {
+ var tokens = new ArrayList<String>();
+ var sb = new StringBuilder();
+ char prev = 0;
+ for (var i = 0; i < name.length(); i++) {
+ var c = name.charAt(i);
+ if (! Character.isLetterOrDigit(c)) {
+ // Separator (., -, /, _, space, …): flush the
current token.
+ if (sb.length() > 0) {
+ tokens.add(sb.toString());
+ sb.setLength(0);
+ }
+ } else {
+ // camelCase boundary: lower/digit followed by
upper starts a new token.
+ if (sb.length() > 0 && Character.isUpperCase(c)
&& (Character.isLowerCase(prev) || Character.isDigit(prev))) {
+ tokens.add(sb.toString());
+ sb.setLength(0);
+ }
+ sb.append(Character.toLowerCase(c));
+ }
+ prev = c;
+ }
+ if (sb.length() > 0)
+ tokens.add(sb.toString());
+ return tokens;
+ }
+
+ private static void addIfNew(List<String> out, String candidate) {
+ if (! out.contains(candidate))
+ out.add(candidate);
+ }
+}
diff --git
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/settings/Settings.java
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/settings/Settings.java
index 5e4e0a2673..5bf3e7d04c 100644
---
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/settings/Settings.java
+++
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/settings/Settings.java
@@ -159,6 +159,19 @@ public class Settings {
*/
public static final PropertySource SYSTEM_ENV_SOURCE =
FunctionalPropertySource.of(System::getenv);
+ /**
+ * {@link #SYSTEM_ENV_SOURCE} wrapped with {@link RelaxedPropertySource
relaxed binding}, so a single logical key
+ * resolves from any of the common environment-variable spellings (e.g.
{@code my.prop} / {@code myProp} /
+ * {@code MySection/myKey} all satisfied by {@code MY_PROP} / {@code
MY_SECTION_MY_KEY}).
+ *
+ * <p>
+ * This is the env source used by the default {@link #get() singleton}.
It is verbatim-first, so exact-match
+ * behavior is unchanged — a relaxed variant is only probed when
the exact name misses.
+ *
+ * @since 10.0.0
+ */
+ public static final PropertySource RELAXED_SYSTEM_ENV_SOURCE = new
RelaxedPropertySource(SYSTEM_ENV_SOURCE);
+
private static final String DISABLE_GLOBAL_PROP =
"juneau.settings.disableGlobal";
private static final String MSG_globalDisabled = "Global settings not
enabled";
private static final String MSG_localDisabled = "Local settings not
enabled";
@@ -334,7 +347,7 @@ public class Settings {
private static final Settings INSTANCE = new Builder()
.globalStore(initProperty(DISABLE_GLOBAL_PROP).map(Boolean::valueOf).orElse(false)
? () -> null : MapStore::new)
- .setSources(SYSTEM_ENV_SOURCE, SYSTEM_PROPERTY_SOURCE)
+ .setSources(RELAXED_SYSTEM_ENV_SOURCE, SYSTEM_PROPERTY_SOURCE)
.useServiceLoader()
.build();
diff --git
a/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/settings/RelaxedPropertySource_Test.java
b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/settings/RelaxedPropertySource_Test.java
new file mode 100644
index 0000000000..4379a33414
--- /dev/null
+++
b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/settings/RelaxedPropertySource_Test.java
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.commons.settings;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.commons.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for {@link RelaxedPropertySource} — relaxed-binding candidate
generation and decorator lookup.
+ */
+class RelaxedPropertySource_Test extends TestBase {
+
+ /** Simple in-memory delegate keyed by exact name. */
+ private static PropertySource map(String... kv) {
+ var m = new LinkedHashMap<String,String>();
+ for (var i = 0; i < kv.length; i += 2)
+ m.put(kv[i], kv[i + 1]);
+ return name -> m.containsKey(name) ?
PropertyLookupResult.present(Optional.ofNullable(m.get(name))) :
PropertyLookupResult.missing();
+ }
+
+ private static String resolve(PropertySource s, String name) {
+ var r = s.get(name);
+ return r.isPresent() ? r.value().orElse(null) : null;
+ }
+
+ //
=================================================================================
+ // A. Candidate generation
+ //
=================================================================================
+
+ @Test void a01_candidates_edges() {
+ // A name with no alphanumerics tokenizes to nothing -> only
the verbatim candidate.
+ assertEquals(List.of("_"),
RelaxedPropertySource.candidates("_"));
+ assertEquals(List.of(""), RelaxedPropertySource.candidates(""));
+ assertEquals(List.of("/"),
RelaxedPropertySource.candidates("/"));
+ // digit->upper camel boundary: "a1B" -> tokens [a1, b].
+ assertEquals(List.of("a1B", "A1_B", "a1.b"),
RelaxedPropertySource.candidates("a1B"));
+ }
+
+ @Test void a02_candidates_basic() {
+ // "my.prop": verbatim, then UPPER_UNDERSCORE (=MY_PROP); the
lower-dotted form equals verbatim so it dedups out.
+ assertEquals(List.of("my.prop", "MY_PROP"),
RelaxedPropertySource.candidates("my.prop"));
+ assertEquals(List.of("myProp", "MY_PROP", "my.prop"),
RelaxedPropertySource.candidates("myProp"));
+ // "MY_PROP": verbatim, then lower-dotted (=my.prop); the
upper-underscore form equals verbatim so it dedups out.
+ assertEquals(List.of("MY_PROP", "my.prop"),
RelaxedPropertySource.candidates("MY_PROP"));
+ assertEquals(List.of("MySection/myKey", "MY_SECTION_MY_KEY",
"my.section.my.key"), RelaxedPropertySource.candidates("MySection/myKey"));
+ assertEquals(List.of("my-prop", "MY_PROP", "my.prop"),
RelaxedPropertySource.candidates("my-prop"));
+ }
+
+ //
=================================================================================
+ // B. Verbatim-first / exact-match preserved
+ //
=================================================================================
+
+ @Test void b01_verbatimWins() {
+ // Both the exact key and a relaxed variant exist; the exact
(verbatim) one must win.
+ var s = new RelaxedPropertySource(map("my.prop", "exact",
"MY_PROP", "relaxed"));
+ assertEquals("exact", resolve(s, "my.prop"));
+ }
+
+ @Test void b02_alreadyCanonicalUnchanged() {
+ var s = new RelaxedPropertySource(map("MY_PROP", "v"));
+ assertEquals("v", resolve(s, "MY_PROP"));
+ }
+
+ //
=================================================================================
+ // C. Relaxed matches
+ //
=================================================================================
+
+ @Test void c01_dottedResolvesEnvStyle() {
+ var s = new RelaxedPropertySource(map("MY_PROP", "v"));
+ assertEquals("v", resolve(s, "my.prop"));
+ }
+
+ @Test void c02_camelResolvesEnvStyle() {
+ var s = new RelaxedPropertySource(map("MY_PROP", "v"));
+ assertEquals("v", resolve(s, "myProp"));
+ }
+
+ @Test void c03_sectionKeyResolvesEnvStyle() {
+ var s = new RelaxedPropertySource(map("MY_SECTION_MY_KEY",
"v"));
+ assertEquals("v", resolve(s, "MySection/myKey"));
+ }
+
+ @Test void c04_envStyleResolvesDotted() {
+ var s = new RelaxedPropertySource(map("my.prop", "v"));
+ assertEquals("v", resolve(s, "MY_PROP"));
+ }
+
+ @Test void c05_missReturnsMissing() {
+ var s = new RelaxedPropertySource(map("OTHER", "v"));
+ assertNull(resolve(s, "my.prop"));
+ assertFalse(s.get("my.prop").isPresent());
+ }
+
+ @Test void c06_nullNameMissing() {
+ var s = new RelaxedPropertySource(map("X", "v"));
+ assertFalse(s.get(null).isPresent());
+ }
+
+ @Test void c07_presentNullValuePreserved() {
+ // A key present with a null value resolves as present-empty,
not missing.
+ var s = new RelaxedPropertySource(name ->
"MY_PROP".equals(name) ? PropertyLookupResult.present(Optional.empty()) :
PropertyLookupResult.missing());
+ var r = s.get("my.prop");
+ assertTrue(r.isPresent());
+ assertTrue(r.value().isEmpty());
+ }
+}
diff --git
a/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/Config.java
b/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/Config.java
index 9148a0fc95..8d70a6dee8 100644
---
a/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/Config.java
+++
b/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/Config.java
@@ -97,6 +97,7 @@ public class Config extends Context implements
ConfigEventListener {
private ConfigFormat format;
private VarResolver varResolver;
private WriterSerializer serializer;
+ private List<String> profiles;
/**
* Constructor, default settings.
@@ -114,6 +115,22 @@ public class Config extends Context implements
ConfigEventListener {
store = FileStore.DEFAULT;
format = null;
varResolver = VarResolver.DEFAULT;
+ profiles = defaultProfiles();
+ }
+
+ /**
+ * Resolves the default active profiles from {@code
juneau.profiles.active} (system property / env var / config),
+ * comma-split and trimmed. Returns an empty list when unset.
+ */
+ private static List<String> defaultProfiles() {
+ var s = env("juneau.profiles.active", "");
+ if (s == null || s.isBlank())
+ return list();
+ var out = new ArrayList<String>();
+ for (var p : s.split(","))
+ if (! p.isBlank())
+ out.add(p.trim());
+ return out;
}
/**
@@ -134,6 +151,7 @@ public class Config extends Context implements
ConfigEventListener {
serializer = copyFrom.serializer;
store = copyFrom.store;
varResolver = copyFrom.varResolver;
+ profiles = copyFrom.profiles == null ? list() : new
ArrayList<>(copyFrom.profiles);
}
/**
@@ -292,6 +310,29 @@ public class Config extends Context implements
ConfigEventListener {
return this;
}
+ /**
+ * Active configuration profiles.
+ *
+ * <p>
+ * Each active profile {@code P} activates a {@code <name>-P}
overlay (e.g. base {@code my.cfg} + profile
+ * {@code stage} → {@code my-stage.cfg}) layered over the
base config: profile entries win over base, and
+ * for multiple active profiles the <b>last</b> one wins. YAML
bases use {@code <name>-P.yml} overlays the same
+ * way. Overlays are resolved through the configured {@link
#store(ConfigStore) store}, so live-reload fires when
+ * either the base or an active profile file changes.
+ *
+ * <p>
+ * Defaults to the comma-separated value of {@code
juneau.profiles.active} (system property, environment variable
+ * — with relaxed binding — or config key). Pass
no arguments / an empty array to disable profiles.
+ *
+ * @param value The active profile names, in activation order.
May be empty.
+ * @return This object.
+ * @since 10.0.0
+ */
+ public Builder profiles(String...value) {
+ profiles = value == null ? list() : list(value);
+ return this;
+ }
+
/**
* Configuration format.
*
@@ -600,7 +641,12 @@ public class Config extends Context implements
ConfigEventListener {
parser = builder.parser;
readOnly = builder.readOnly;
serializer = builder.serializer;
- store = builder.store;
+ // When profiles are active, wrap the configured store so reads
of the base name return the base config
+ // with each active profile's <name>-<profile> overlay merged
on top (profile-wins, last-active-wins).
+ var profiles2 = builder.profiles == null ?
Collections.<String>emptyList() : builder.profiles;
+ store = profiles2.isEmpty()
+ ? builder.store
+ :
ProfileConfigStore.create().delegate(builder.store).baseName(name).profiles(profiles2).format(format).build();
varResolver = builder.varResolver;
configMap = store.getMap(name, format);
configMap.register(this);
diff --git
a/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/internal/ProfileMerge.java
b/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/internal/ProfileMerge.java
new file mode 100644
index 0000000000..9a7c6dd53b
--- /dev/null
+++
b/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/internal/ProfileMerge.java
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.config.internal;
+
+import java.io.*;
+import java.util.*;
+
+import org.apache.juneau.config.format.*;
+import org.apache.juneau.config.store.*;
+
+/**
+ * Merges a base configuration with one or more profile overlays into a single
internal-INI string.
+ *
+ * <p>
+ * This is the engine behind config <b>profiles</b> ({@code
<name>-<profile>.cfg}/{@code .yml} overlays activated via
+ * {@code juneau.profiles.active}). The base config is the foundation; each
active profile's entries are layered on
+ * top in activation order so that:
+ * <ul>
+ * <li><b>profile wins over base</b> for any section/key the profile
redefines, and
+ * <li><b>last-active-profile wins</b> when multiple active profiles
redefine the same section/key.
+ * </ul>
+ *
+ * <p>
+ * Each input (base + profiles) is parsed through its own {@link ConfigFormat}
(so a YAML profile is converted to the
+ * internal INI form like any other config), then the parsed maps are overlaid
entry-by-entry and re-emitted as a
+ * single internal-INI string via {@link ConfigMap#asIniString()}. Feeding
that merged string back through the normal
+ * {@link ConfigMap} parse keeps format handling, variable resolution, and
change-listener wiring identical to a
+ * single-file config — profiles are transparent to everything
downstream of the store.
+ *
+ * @since 10.0.0
+ */
+public final class ProfileMerge {
+
+ private ProfileMerge() {}
+
+ /**
+ * Merges base + profile contents into one internal-INI string
(profile-wins, last-active-profile-wins).
+ *
+ * @param store The store the throwaway parse maps are associated with
(used only for parse context; never written).
+ * @param baseName The base config name (for parse diagnostics).
+ * @param baseContents The raw base config contents (format-native).
+ * @param profiles Ordered profile overlays (activation order); each is
a raw format-native contents string. May be empty.
+ * @param format The config format used to parse every input.
+ * @return The merged contents as an internal-INI string.
+ * @throws IOException If any input fails to parse.
+ */
+ public static String merge(ConfigStore store, String baseName, String
baseContents, List<String> profiles, ConfigFormat format) throws IOException {
+ var f = format == null ? IniConfigFormat.INSTANCE : format;
+ // Parse the base into a throwaway map; this becomes the
accumulator we overlay onto.
+ var merged = new ConfigMap(store, baseName, baseContents ==
null ? "" : baseContents, f);
+ for (var p : profiles) {
+ if (p == null || p.isBlank())
+ continue;
+ var overlay = new ConfigMap(store, baseName, p, f);
+ applyOverlay(merged, overlay);
+ }
+ return merged.asIniString();
+ }
+
+ /**
+ * Overlays every section/key entry from {@code overlay} onto {@code
base} (overlay wins).
+ */
+ private static void applyOverlay(ConfigMap base, ConfigMap overlay) {
+ for (var section : overlay.getSections()) {
+ // Ensure the section exists on the base (preserving
the overlay's section pre-lines when it is new).
+ if (! base.hasSection(section))
+ base.setSection(section,
overlay.getPreLines(section));
+ for (var key : overlay.getKeys(section)) {
+ var e = overlay.getEntry(section, key);
+ if (e != null) // HTT: getKeys() only returns
keys with entries, so getEntry is never null here.
+ base.setEntry(section, key,
e.getValue(), e.getModifiers(), e.getComment(), e.getPreLines());
+ }
+ }
+ }
+}
diff --git
a/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/store/ProfileConfigStore.java
b/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/store/ProfileConfigStore.java
new file mode 100644
index 0000000000..2502439e2e
--- /dev/null
+++
b/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/store/ProfileConfigStore.java
@@ -0,0 +1,231 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.config.store;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+import static org.apache.juneau.commons.utils.Utils.*;
+
+import java.io.*;
+import java.util.*;
+
+import org.apache.juneau.config.format.*;
+import org.apache.juneau.config.internal.*;
+import org.apache.juneau.marshall.*;
+
+/**
+ * A {@link ConfigStore} decorator that layers <b>profile overlays</b> over a
delegate store's base config.
+ *
+ * <p>
+ * Given a set of active profiles (e.g. {@code ["stage"]} or {@code
["base","cloud"]}) and a base config name
+ * {@code N}, a {@code read(N)} returns the base contents from the delegate
with each active profile's
+ * {@code N-<profile>} overlay merged on top — profile wins over base,
last-active-profile wins over earlier
+ * ones (see {@link ProfileMerge}). Reads for any name <i>other</i> than the
configured base name pass straight
+ * through to the delegate.
+ *
+ * <p>
+ * <b>Live reload:</b> the store registers a listener on the delegate for the
base name <i>and</i> each profile name,
+ * so a change to the base file or any active profile file re-runs the merge
and re-fires the change to this store's
+ * own listeners (the {@link ConfigMap} sees a fresh merged contents and
reloads).
+ *
+ * <p>
+ * The profile file name is derived from the base name by inserting {@code
-<profile>} before the extension
+ * (e.g. base {@code my.cfg} + profile {@code stage} → {@code
my-stage.cfg}); a base with no extension simply
+ * appends {@code -<profile>}. Writes, existence checks, and name resolution
delegate to the wrapped store.
+ *
+ * @since 10.0.0
+ */
+public class ProfileConfigStore extends ConfigStore {
+
+ /**
+ * Builder class.
+ */
+ public static class Builder extends ConfigStore.Builder<Builder> {
+
+ ConfigStore delegate;
+ String baseName;
+ List<String> profiles = new ArrayList<>();
+ ConfigFormat format = IniConfigFormat.INSTANCE;
+
+ /** Constructor, default settings. */
+ protected Builder() {}
+
+ /**
+ * Copy constructor.
+ *
+ * @param copyFrom The builder to copy from. Cannot be
<jk>null</jk>.
+ */
+ protected Builder(Builder copyFrom) {
+ super(assertArgNotNull("copyFrom", copyFrom));
+ this.delegate = copyFrom.delegate;
+ this.baseName = copyFrom.baseName;
+ this.profiles = new ArrayList<>(copyFrom.profiles);
+ this.format = copyFrom.format;
+ }
+
+ /**
+ * Copy constructor.
+ *
+ * @param copyFrom The bean to copy from. Cannot be
<jk>null</jk>.
+ */
+ protected Builder(ProfileConfigStore copyFrom) {
+ super(assertArgNotNull("copyFrom", copyFrom));
+ type(copyFrom.getClass());
+ this.delegate = copyFrom.delegate;
+ this.baseName = copyFrom.baseName;
+ this.profiles = new ArrayList<>(copyFrom.profiles);
+ this.format = copyFrom.format;
+ }
+
+ /**
+ * Sets the wrapped delegate store that supplies the base +
profile file contents.
+ *
+ * @param value The delegate store.
+ * @return This object.
+ */
+ public Builder delegate(ConfigStore value) { delegate = value;
return this; }
+
+ /**
+ * Sets the base config name whose {@code -<profile>} overlays
are merged.
+ *
+ * @param value The base config name.
+ * @return This object.
+ */
+ public Builder baseName(String value) { baseName = value;
return this; }
+
+ /**
+ * Sets the active profiles, in activation order
(last-active-profile wins).
+ *
+ * @param value The active profile names.
+ * @return This object.
+ */
+ public Builder profiles(List<String> value) { profiles = value
== null ? new ArrayList<>() : new ArrayList<>(value); return this; }
+
+ /**
+ * Sets the config format used to parse the base + profile
files.
+ *
+ * @param value The format.
+ * @return This object.
+ */
+ public Builder format(ConfigFormat value) { format = value ==
null ? IniConfigFormat.INSTANCE : value; return this; }
+
+ @Override /* Overridden from Context.Builder<?> */
+ public ProfileConfigStore build() {
+ return build(ProfileConfigStore.class);
+ }
+
+ @Override /* Overridden from Context.Builder<?> */
+ public Builder copy() {
+ return new Builder(this);
+ }
+ }
+
+ /**
+ * Creates a new builder for this object.
+ *
+ * @return A new builder.
+ */
+ public static Builder create() {
+ return new Builder();
+ }
+
+ private final ConfigStore delegate;
+ private final String baseName;
+ private final List<String> profiles;
+ private final ConfigFormat format;
+ private final ConfigStoreListener reloadListener;
+
+ /**
+ * Constructor.
+ *
+ * @param builder The builder for this object.
+ */
+ public ProfileConfigStore(Builder builder) {
+ super(builder);
+ this.delegate = assertArgNotNull("delegate", builder.delegate);
+ this.baseName = assertArgNotNull("baseName", builder.baseName);
+ this.profiles = List.copyOf(builder.profiles);
+ this.format = builder.format;
+
+ // Re-merge + re-notify whenever the base or any active profile
file changes underneath us.
+ this.reloadListener = contents -> {
+ try {
+ update(baseName, mergedContents());
+ } catch (IOException e) { // HTT: re-merge IO failure
only on a real FileStore IO error mid-reload; not reproducible with in-memory
stores.
+ throw new ConfigException(e, "Failed to
re-merge profile overlays for ''{0}''", baseName);
+ }
+ };
+ delegate.register(baseName, reloadListener);
+ for (var p : profiles)
+ delegate.register(profileName(p), reloadListener);
+ }
+
+ @Override /* Overridden from Context */
+ public Builder copy() {
+ return new Builder(this);
+ }
+
+ @Override /* Overridden from Closeable */
+ public void close() throws IOException {
+ delegate.unregister(baseName, reloadListener);
+ for (var p : profiles)
+ delegate.unregister(profileName(p), reloadListener);
+ delegate.close();
+ }
+
+ @Override /* Overridden from ConfigStore */
+ public boolean exists(String name) {
+ return delegate.exists(name);
+ }
+
+ @Override /* Overridden from ConfigStore */
+ public String read(String name) throws IOException {
+ if (! eq(name, baseName))
+ return delegate.read(name);
+ return mergedContents();
+ }
+
+ @Override /* Overridden from ConfigStore */
+ public String write(String name, String expectedContents, String
newContents) throws IOException {
+ return delegate.write(name, expectedContents, newContents);
+ }
+
+ /**
+ * Reads the base + each active profile from the delegate and merges
them (profile-wins, last-active-wins).
+ */
+ private String mergedContents() throws IOException {
+ var base = delegate.read(baseName);
+ if (profiles.isEmpty())
+ return base;
+ var overlays = new ArrayList<String>(profiles.size());
+ for (var p : profiles)
+ overlays.add(delegate.read(profileName(p)));
+ return ProfileMerge.merge(delegate, baseName, base, overlays,
format);
+ }
+
+ /**
+ * Derives the profile file name from the base name by inserting {@code
-<profile>} before the extension.
+ *
+ * @param profile The profile name.
+ * @return The profile config name.
+ */
+ String profileName(String profile) {
+ var dot = baseName.lastIndexOf('.');
+ if (dot < 0)
+ return baseName + "-" + profile;
+ return baseName.substring(0, dot) + "-" + profile +
baseName.substring(dot);
+ }
+}
diff --git
a/juneau-core/juneau-config/src/test/java/org/apache/juneau/config/ConfigProfiles_Test.java
b/juneau-core/juneau-config/src/test/java/org/apache/juneau/config/ConfigProfiles_Test.java
new file mode 100644
index 0000000000..aea0a2a70a
--- /dev/null
+++
b/juneau-core/juneau-config/src/test/java/org/apache/juneau/config/ConfigProfiles_Test.java
@@ -0,0 +1,154 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.config;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.config.event.*;
+import org.apache.juneau.config.store.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for config <b>profiles</b> — {@code <name>-<profile>} overlays
activated via
+ * {@code Config.Builder.profiles(...)} (the standalone path; the Spring
piggyback is covered in the springboot module).
+ */
+class ConfigProfiles_Test extends TestBase {
+
+ @SuppressWarnings({
+ "resource" // MemoryStore/Config are test fixtures; lifecycle
managed by the test, not a real leak.
+ })
+ private static MemoryStore store(String... namesAndContents) {
+ var s = MemoryStore.create().build();
+ for (var i = 0; i < namesAndContents.length; i += 2)
+ s.update(namesAndContents[i], namesAndContents[i + 1]);
+ return s;
+ }
+
+ @SuppressWarnings({
+ "resource" // see above.
+ })
+ private static Config config(MemoryStore s, String name, String...
profiles) {
+ try {
+ return
Config.create().store(s).name(name).profiles(profiles).build();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ //
=================================================================================
+ // A. Basic overlay — profile wins over base; base-only keys survive.
+ //
=================================================================================
+
+ @Test void a01_profileOverridesBase_baseOnlySurvives() {
+ var s = store(
+ "App.cfg", "[S]\na = base-a\nb = base-b\n",
+ "App-stage.cfg", "[S]\na = stage-a\n");
+ var c = config(s, "App.cfg", "stage");
+ assertEquals("stage-a", c.get("S/a").orElse(null)); // profile
wins
+ assertEquals("base-b", c.get("S/b").orElse(null)); //
base-only survives
+ }
+
+ @Test void a02_noProfilesIsBaseOnly() {
+ var s = store("App.cfg", "[S]\na = base-a\n", "App-stage.cfg",
"[S]\na = stage-a\n");
+ var c = config(s, "App.cfg");
+ assertEquals("base-a", c.get("S/a").orElse(null));
+ }
+
+ @Test void a03_newSectionFromProfile() {
+ var s = store(
+ "App.cfg", "[S]\na = base-a\n",
+ "App-stage.cfg", "[T]\nx = stage-x\n");
+ var c = config(s, "App.cfg", "stage");
+ assertEquals("base-a", c.get("S/a").orElse(null));
+ assertEquals("stage-x", c.get("T/x").orElse(null)); // section
only in the profile
+ }
+
+ //
=================================================================================
+ // B. Multiple active profiles — last-active-profile wins.
+ //
=================================================================================
+
+ @Test void b01_lastActiveProfileWins() {
+ var s = store(
+ "App.cfg", "[S]\na = base\n",
+ "App-one.cfg", "[S]\na = one\n",
+ "App-two.cfg", "[S]\na = two\n");
+ var c = config(s, "App.cfg", "one", "two");
+ assertEquals("two", c.get("S/a").orElse(null)); // two
activated last
+ }
+
+ @Test void b02_earlierProfileStillContributesUnsharedKeys() {
+ var s = store(
+ "App.cfg", "[S]\na = base\n",
+ "App-one.cfg", "[S]\nb = one-b\n",
+ "App-two.cfg", "[S]\na = two-a\n");
+ var c = config(s, "App.cfg", "one", "two");
+ assertEquals("two-a", c.get("S/a").orElse(null)); // two wins
for a
+ assertEquals("one-b", c.get("S/b").orElse(null)); // one's
unique key survives
+ }
+
+ //
=================================================================================
+ // C. Missing profile file is a no-op overlay (base unchanged).
+ //
=================================================================================
+
+ @Test void c01_missingProfileFileNoOp() {
+ var s = store("App.cfg", "[S]\na = base-a\n"); // no
App-stage.cfg seeded
+ var c = config(s, "App.cfg", "stage");
+ assertEquals("base-a", c.get("S/a").orElse(null));
+ }
+
+ //
=================================================================================
+ // D. Live reload — a change to a profile overlay file re-fires
config-change listeners.
+ //
=================================================================================
+
+ @Test void d01_profileFileChangeTriggersReload() throws Exception {
+ var s = store(
+ "App.cfg", "[S]\na = base-a\n",
+ "App-stage.cfg", "[S]\na = stage-a\n");
+ var c = config(s, "App.cfg", "stage");
+ assertEquals("stage-a", c.get("S/a").orElse(null));
+
+ var latch = new CountDownLatch(1);
+ var seen = new AtomicReference<String>();
+ c.addListener((ConfigEventListener) events -> {
seen.set("fired"); latch.countDown(); });
+
+ // Change the PROFILE file underneath the store.
+ s.update("App-stage.cfg", "[S]\na = stage-a2\n");
+
+ assertTrue(latch.await(5, TimeUnit.SECONDS), "Expected a
config-change event from the profile-file update");
+ assertEquals("stage-a2", c.get("S/a").orElse(null)); //
re-merged value visible
+ }
+
+ @Test void d02_baseFileChangeTriggersReload() throws Exception {
+ var s = store(
+ "App.cfg", "[S]\na = base-a\nb = base-b\n",
+ "App-stage.cfg", "[S]\na = stage-a\n");
+ var c = config(s, "App.cfg", "stage");
+
+ var latch = new CountDownLatch(1);
+ c.addListener((ConfigEventListener) events ->
latch.countDown());
+
+ s.update("App.cfg", "[S]\na = base-a\nb = base-b2\n");
+
+ assertTrue(latch.await(5, TimeUnit.SECONDS), "Expected a
config-change event from the base-file update");
+ assertEquals("stage-a", c.get("S/a").orElse(null)); //
profile still wins after base reload
+ assertEquals("base-b2", c.get("S/b").orElse(null)); // base
change visible
+ }
+}
diff --git
a/juneau-core/juneau-config/src/test/java/org/apache/juneau/config/store/ProfileConfigStore_Test.java
b/juneau-core/juneau-config/src/test/java/org/apache/juneau/config/store/ProfileConfigStore_Test.java
new file mode 100644
index 0000000000..93fdeea442
--- /dev/null
+++
b/juneau-core/juneau-config/src/test/java/org/apache/juneau/config/store/ProfileConfigStore_Test.java
@@ -0,0 +1,156 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.config.store;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.config.format.*;
+import org.apache.juneau.config.internal.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Direct tests for {@link ProfileConfigStore} (the profile-overlay store
decorator) and {@link ProfileMerge}
+ * (the overlay engine), exercising read pass-through, delegation, name
derivation, lifecycle, and the merge edges.
+ */
+@SuppressWarnings({
+ "resource" // MemoryStore/ProfileConfigStore are test fixtures;
lifecycle managed by the test, not a real leak.
+})
+class ProfileConfigStore_Test extends TestBase {
+
+ private static MemoryStore store(String... kv) {
+ var s = MemoryStore.create().build();
+ for (var i = 0; i < kv.length; i += 2)
+ s.update(kv[i], kv[i + 1]);
+ return s;
+ }
+
+ private static ProfileConfigStore wrap(MemoryStore delegate, String
baseName, String... profiles) {
+ return
ProfileConfigStore.create().delegate(delegate).baseName(baseName).profiles(Arrays.asList(profiles)).format(IniConfigFormat.INSTANCE).build();
+ }
+
+ //
=================================================================================
+ // A. read() — base name merges; other names pass through.
+ //
=================================================================================
+
+ @Test void a01_readBaseNameMerges() throws Exception {
+ var d = store("App.cfg", "[S]\na = base\n", "App-stage.cfg",
"[S]\na = stage\n");
+ var p = wrap(d, "App.cfg", "stage");
+ assertTrue(p.read("App.cfg").contains("stage"));
+ }
+
+ @Test void a02_readOtherNamePassesThrough() throws Exception {
+ var d = store("App.cfg", "[S]\na = base\n", "Other.cfg",
"[S]\nx = y\n");
+ var p = wrap(d, "App.cfg", "stage");
+ assertTrue(p.read("Other.cfg").contains("x = y"));
+ }
+
+ @Test void a03_noProfilesReturnsBaseVerbatim() throws Exception {
+ var d = store("App.cfg", "[S]\na = base\n");
+ var p = wrap(d, "App.cfg"); // no profiles
+ assertTrue(p.read("App.cfg").contains("base"));
+ }
+
+ //
=================================================================================
+ // B. Delegation — exists / write delegate to the wrapped store.
+ //
=================================================================================
+
+ @Test void b01_existsDelegates() {
+ var d = store("App.cfg", "x");
+ var p = wrap(d, "App.cfg", "stage");
+ assertTrue(p.exists("App.cfg"));
+ assertFalse(p.exists("Nope.cfg"));
+ }
+
+ @Test void b02_writeDelegates() throws Exception {
+ var d = store("App.cfg", "[S]\na = base\n");
+ var p = wrap(d, "App.cfg", "stage");
+ p.write("Other.cfg", null, "[S]\nx = written\n");
+ assertTrue(d.read("Other.cfg").contains("written"));
+ }
+
+ //
=================================================================================
+ // C. profileName() — inserts -<profile> before the extension; appends
when none.
+ //
=================================================================================
+
+ @Test void c01_profileNameWithExtension() {
+ var p = wrap(store("App.cfg", ""), "App.cfg", "stage");
+ assertEquals("App-stage.cfg", p.profileName("stage"));
+ }
+
+ @Test void c02_profileNameNoExtension() {
+ var p = wrap(store("App", ""), "App", "stage");
+ assertEquals("App-stage", p.profileName("stage"));
+ }
+
+ //
=================================================================================
+ // D. Lifecycle — copy() round-trips; close() unregisters + closes
delegate.
+ //
=================================================================================
+
+ @Test void d01_copyRoundTrips() {
+ var p = wrap(store("App.cfg", ""), "App.cfg", "stage");
+ var p2 = p.copy().build(); // store.copy() ->
Builder(ProfileConfigStore)
+ assertEquals("App-stage.cfg", p2.profileName("stage"));
+ }
+
+ @Test void d01b_builderCopyRoundTrips() {
+ var d = store("App.cfg", "");
+ var b =
ProfileConfigStore.create().delegate(d).baseName("App.cfg").profiles(List.of("stage")).format(IniConfigFormat.INSTANCE);
+ var p = b.copy().build(); // Builder.copy() -> Builder(Builder)
+ assertEquals("App-stage.cfg", p.profileName("stage"));
+ }
+
+ @Test void d02_closeIsClean() {
+ var d = store("App.cfg", "[S]\na = base\n", "App-stage.cfg",
"[S]\na = stage\n");
+ var p = wrap(d, "App.cfg", "stage");
+ assertDoesNotThrow(p::close); // unregisters listeners +
closes delegate
+ }
+
+ //
=================================================================================
+ // E. ProfileMerge edges — null format, null base, blank/empty profiles.
+ //
=================================================================================
+
+ @Test void e01_mergeNullFormatDefaultsIni() throws Exception {
+ var d = store();
+ var merged = ProfileMerge.merge(d, "App.cfg", "[S]\na =
base\n", List.of("[S]\na = over\n"), null);
+ assertTrue(merged.contains("over"));
+ }
+
+ @Test void e02_mergeNullBaseContents() throws Exception {
+ var d = store();
+ var merged = ProfileMerge.merge(d, "App.cfg", null,
List.of("[S]\na = over\n"), IniConfigFormat.INSTANCE);
+ assertTrue(merged.contains("over"));
+ }
+
+ @Test void e03_mergeBlankAndNullProfilesSkipped() throws Exception {
+ var d = store();
+ var profiles = new ArrayList<String>();
+ profiles.add(""); // blank — skipped
+ profiles.add(null); // null — skipped
+ profiles.add("[S]\na = over\n");
+ var merged = ProfileMerge.merge(d, "App.cfg", "[S]\na =
base\n", profiles, IniConfigFormat.INSTANCE);
+ assertTrue(merged.contains("over"));
+ }
+
+ @Test void e04_mergeNoProfilesReturnsBase() throws Exception {
+ var d = store();
+ var merged = ProfileMerge.merge(d, "App.cfg", "[S]\na =
base\n", List.of(), IniConfigFormat.INSTANCE);
+ assertTrue(merged.contains("base"));
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server-springboot/src/main/java/org/apache/juneau/rest/server/springboot/SpringEnvironmentPropertySource.java
b/juneau-rest/juneau-rest-server-springboot/src/main/java/org/apache/juneau/rest/server/springboot/SpringEnvironmentPropertySource.java
index 851bc719c0..472a602921 100644
---
a/juneau-rest/juneau-rest-server-springboot/src/main/java/org/apache/juneau/rest/server/springboot/SpringEnvironmentPropertySource.java
+++
b/juneau-rest/juneau-rest-server-springboot/src/main/java/org/apache/juneau/rest/server/springboot/SpringEnvironmentPropertySource.java
@@ -115,11 +115,25 @@ public class SpringEnvironmentPropertySource implements
org.apache.juneau.common
}
}
}
- if (e == null || ! e.containsProperty(name))
+ if (e == null)
+ return
org.apache.juneau.commons.settings.PropertyLookupResult.missing();
+ // Profile piggyback: a Juneau config-profile activation lookup
resolves to Spring's active profiles, so a
+ // Spring-Boot deployment has a single source of truth for
which profiles are active. Only answered when the
+ // caller has NOT explicitly set juneau.profiles.active as a
property (that exact key wins if present).
+ if (PROFILES_ACTIVE_KEY.equals(name) && !
e.containsProperty(name)) {
+ var active = e.getActiveProfiles();
+ if (active != null && active.length > 0) // HTT:
getActiveProfiles() never returns null per the Environment contract; the guard
is defensive.
+ return
org.apache.juneau.commons.settings.PropertyLookupResult.present(opt(String.join(",",
active)));
+ return
org.apache.juneau.commons.settings.PropertyLookupResult.missing();
+ }
+ if (! e.containsProperty(name))
return
org.apache.juneau.commons.settings.PropertyLookupResult.missing();
// Spring's getProperty() returns null only for unresolved
placeholders, which
// containsProperty() already filtered out. Wrap defensively
anyway.
var v = e.getProperty(name);
return
org.apache.juneau.commons.settings.PropertyLookupResult.present(opt(v));
}
+
+ /** The Juneau config-profile activation key that piggybacks on
Spring's active profiles. */
+ private static final String PROFILES_ACTIVE_KEY =
"juneau.profiles.active";
}
diff --git
a/juneau-rest/juneau-rest-server-springboot/src/test/java/org/apache/juneau/rest/server/springboot/SpringEnvironmentPropertySource_Test.java
b/juneau-rest/juneau-rest-server-springboot/src/test/java/org/apache/juneau/rest/server/springboot/SpringEnvironmentPropertySource_Test.java
index 68b6937a8a..340921b1dc 100644
---
a/juneau-rest/juneau-rest-server-springboot/src/test/java/org/apache/juneau/rest/server/springboot/SpringEnvironmentPropertySource_Test.java
+++
b/juneau-rest/juneau-rest-server-springboot/src/test/java/org/apache/juneau/rest/server/springboot/SpringEnvironmentPropertySource_Test.java
@@ -237,4 +237,31 @@ class SpringEnvironmentPropertySource_Test extends
TestBase {
store.clear();
}
}
+
+ //
=================================================================================
+ // D. Config-profile piggyback — juneau.profiles.active resolves to
Spring's active profiles.
+ //
=================================================================================
+
+ @Test void d01_profilesActiveResolvesToSpringActiveProfiles() {
+ var env = new MockEnvironment();
+ env.setActiveProfiles("stage", "cloud");
+ var src = new SpringEnvironmentPropertySource(env);
+ var r = src.get("juneau.profiles.active");
+ assertTrue(r.isPresent());
+ assertEquals("stage,cloud", r.value().orElse(null));
+ }
+
+ @Test void d02_noActiveProfilesMissing() {
+ var env = new MockEnvironment(); // no active profiles
+ var src = new SpringEnvironmentPropertySource(env);
+ assertFalse(src.get("juneau.profiles.active").isPresent());
+ }
+
+ @Test void d03_explicitPropertyWinsOverActiveProfiles() {
+ // An explicit juneau.profiles.active property takes precedence
over getActiveProfiles().
+ var env = new
MockEnvironment().withProperty("juneau.profiles.active", "explicit");
+ env.setActiveProfiles("stage");
+ var src = new SpringEnvironmentPropertySource(env);
+ assertEquals("explicit",
src.get("juneau.profiles.active").value().orElse(null));
+ }
}