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 0faddc98d3 feat: per-RestContext @Value resolution against
@Rest(config=...) Configs via BeanStore seam (TODO-95)
0faddc98d3 is described below
commit 0faddc98d39730e5c275ae8cd6fb8b4ff80df25d
Author: James Bognar <[email protected]>
AuthorDate: Wed May 27 09:03:48 2026 -0400
feat: per-RestContext @Value resolution against @Rest(config=...) Configs
via BeanStore seam (TODO-95)
---
.../juneau/commons/inject/ValueResolver.java | 86 ++++-
.../apache/juneau/commons/reflect/FieldInfo.java | 4 +-
.../juneau/commons/reflect/ParameterInfo.java | 4 +-
.../apache/juneau/commons/settings/Settings.java | 75 +++++
.../juneau/commons/svl/vars/PropertyVar.java | 34 ++
.../java/org/apache/juneau/rest/RestContext.java | 113 ++++++-
.../juneau/rest/Settings_NoLeakedSources_Test.java | 137 ++++++++
.../RestContext_ValueAgainstConfig_Test.java | 375 +++++++++++++++++++++
8 files changed, 818 insertions(+), 10 deletions(-)
diff --git
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/inject/ValueResolver.java
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/inject/ValueResolver.java
index aab1469256..75dea4ad9a 100644
---
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/inject/ValueResolver.java
+++
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/inject/ValueResolver.java
@@ -159,10 +159,37 @@ public final class ValueResolver {
* @return The coerced value.
*/
public static Object resolve(String expression, Class<?> targetType,
String siteDescription) {
+ return resolve(expression, targetType, siteDescription, null);
+ }
+
+ /**
+ * Same as {@link #resolve(String, Class, String)} but consults
caller-scoped
+ * {@link PropertySource} beans resolved from the supplied {@link
BeanStore} between
+ * the {@link Settings} local/global override stores and the {@link
Settings} sources chain.
+ *
+ * <p>
+ * The {@code beanStore} parameter is preserved by-reference; if its
+ * {@code PropertySource}-typed contents change between calls, the next
resolution picks up
+ * the current state. Passing {@code null} (or a {@code BeanStore} with
no
+ * {@code PropertySource}-typed beans) is byte-for-byte equivalent to
the zero-argument
+ * overload — no extra allocation and no behavior change.
+ *
+ * @param expression The {@code @Value} expression.
+ * @param targetType The target Java type. May be a primitive.
+ * @param siteDescription A human-readable description of the site
(used in error messages).
+ * @param beanStore Caller-scoped {@link BeanStore} consulted for
{@link PropertySource} beans.
+ * May be {@code null}.
+ * @return The coerced value.
+ */
+ public static Object resolve(String expression, Class<?> targetType,
String siteDescription, BeanStore beanStore) {
if (expression == null)
return resolveCoerce(null, targetType, expression,
siteDescription);
var template = getCompiledTemplate(expression);
- var resolved =
template.resolve(VarResolver.DEFAULT.createSession());
+ var session = VarResolver.DEFAULT.createSession();
+ var sources = scopedSources(beanStore);
+ if (sources != null)
+ session.bean(PropertySource[].class, sources);
+ var resolved = template.resolve(session);
return resolveCoerce(resolved, targetType, expression,
siteDescription);
}
@@ -190,6 +217,28 @@ public final class ValueResolver {
* @return Either a one-shot coerced value, or a {@code
Supplier<String>} factory.
*/
public static Object resolve(String expression, Class<?> targetType,
Type genericTargetType, String siteDescription) {
+ return resolve(expression, targetType, genericTargetType,
siteDescription, null);
+ }
+
+ /**
+ * Same as {@link #resolve(String, Class, Type, String)} but consults
caller-scoped
+ * {@link PropertySource} beans resolved from the supplied {@link
BeanStore}.
+ *
+ * <p>
+ * The {@code beanStore} reference is captured (not snapshotted) by the
returned
+ * {@code Supplier<String>} when the declared field/parameter type is
{@code Supplier<String>},
+ * so re-evaluating reads always see the current state of {@code
beanStore.getBeansOfType(PropertySource.class)}.
+ *
+ * @param expression The {@code @Value} expression. May be {@code null}.
+ * @param targetType The erased target class.
+ * @param genericTargetType The declared generic type. May be {@code
null}.
+ * @param siteDescription Human-readable site description for error
messages.
+ * @param beanStore Caller-scoped {@link BeanStore} consulted for
{@link PropertySource} beans.
+ * May be {@code null}.
+ * @return Either a one-shot coerced value, or a {@code
Supplier<String>} factory.
+ */
+ public static Object resolve(String expression, Class<?> targetType,
Type genericTargetType, String siteDescription,
+ BeanStore beanStore) {
if (isSupplierOfString(targetType, genericTargetType)) {
if (expression == null)
return (Supplier<String>) () -> null;
@@ -201,9 +250,40 @@ public final class ValueResolver {
var literal =
template.resolve(VarResolver.DEFAULT.createSession());
return (Supplier<String>) () -> literal;
}
- return
template.asSupplierWithFreshSessions(VarResolver.DEFAULT);
+ if (beanStore == null)
+ return
template.asSupplierWithFreshSessions(VarResolver.DEFAULT);
+ // Capture the BeanStore by-reference so re-evaluating
reads see the current state of
+ // its PropertySource-typed beans (matches the
"by-reference, not snapshot" contract).
+ final BeanStore scope = beanStore;
+ return (Supplier<String>) () -> {
+ var s = VarResolver.DEFAULT.createSession();
+ var sources = scopedSources(scope);
+ if (sources != null)
+ s.bean(PropertySource[].class, sources);
+ return template.resolve(s);
+ };
}
- return resolve(expression, targetType, siteDescription);
+ return resolve(expression, targetType, siteDescription,
beanStore);
+ }
+
+ /**
+ * Returns the caller-scoped {@link PropertySource} array for the
supplied {@link BeanStore},
+ * or {@code null} if the store has no {@code PropertySource}-typed
beans (matching the
+ * "no behavior change when no scoped sources are present" contract).
+ *
+ * <p>
+ * Walks {@link BeanStore#getBeansOfType(Class)
beanStore.getBeansOfType(PropertySource.class)}.
+ * The returned array preserves the iteration order of {@code
getBeansOfType} (parent-chain
+ * beans before local beans before overriding-parent beans, then sorted
by
+ * {@code @Order/@Primary/@Bean.priority()}).
+ */
+ private static PropertySource[] scopedSources(BeanStore beanStore) {
+ if (beanStore == null)
+ return null;
+ var map = beanStore.getBeansOfType(PropertySource.class);
+ if (map.isEmpty())
+ return null;
+ return map.values().toArray(new PropertySource[0]);
}
/** Returns {@code true} if the declared field/parameter type is
exactly {@code Supplier<String>}. */
diff --git
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/reflect/FieldInfo.java
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/reflect/FieldInfo.java
index 26d416c62d..ee8ecc702b 100644
---
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/reflect/FieldInfo.java
+++
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/reflect/FieldInfo.java
@@ -607,7 +607,9 @@ public class FieldInfo extends AccessibleInfo implements
Comparable<FieldInfo>,
var unwrapped =
fieldType.unwrap(Optional.class).inner();
// Pass the declared (generic) field type so @Value
Supplier<String> autodetects
// Field type IS the opt-in signal for re-evaluating
reads (Supplier<String> autodetect).
- var resolved = ValueResolver.resolve(valueExpr,
unwrapped, inner.getGenericType(), this.toString());
+ // BeanStore is forwarded so caller-scoped
PropertySource beans (e.g. per-RestContext
+ // @Rest(config=...) Configs) participate in expression
resolution alongside Settings.
+ var resolved = ValueResolver.resolve(valueExpr,
unwrapped, inner.getGenericType(), this.toString(), beanStore);
if (fieldType.is(Optional.class)) {
// VarResolver substitutes "" for a missing key
with no default. Collapse both to
// Optional.empty() so @Value("${maybe}")
Optional<T> behaves the same as Spring's.
diff --git
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/reflect/ParameterInfo.java
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/reflect/ParameterInfo.java
index 3de9320b1c..6105948335 100644
---
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/reflect/ParameterInfo.java
+++
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/reflect/ParameterInfo.java
@@ -902,7 +902,9 @@ public class ParameterInfo extends ElementInfo implements
Annotatable {
ValueResolver.checkInjectConflict(annos,
this.toString());
// Pass the declared (generic) parameter type so @Value
Supplier<String> autodetects
// Supplier<String> field/parameter type opts in to
re-evaluating reads.
- return ValueResolver.resolve(valueExpr, ptu.inner(),
inner.getParameterizedType(), this.toString());
+ // BeanStore is forwarded so caller-scoped
PropertySource beans (e.g. per-RestContext
+ // @Rest(config=...) Configs) participate in expression
resolution alongside Settings.
+ return ValueResolver.resolve(valueExpr, ptu.inner(),
inner.getParameterizedType(), this.toString(), beanStore);
}
if (JsrSupport.isProviderType(ptu.inner())) {
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 be6fef0cc8..2336c08bbd 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
@@ -377,6 +377,23 @@ public class Settings {
return this;
}
+ /**
+ * Returns the number of {@link PropertySource}s currently registered
with this
+ * {@code Settings} instance.
+ *
+ * <p>
+ * Intended primarily for tests that need to verify nothing has leaked
extra sources into the
+ * process-wide {@link #get() Settings.get()} singleton — the
historical failure mode
+ * documented in {@code FINISHED-79-phase6-discovery-report.md} where a
per-{@code RestContext}
+ * config bridge added a {@code ConfigPropertySource} per cached {@code
RestContext} via
+ * {@link #addSource(PropertySource)} and never removed it.
+ *
+ * @return The current source count. Always {@code >= 0}.
+ */
+ public int sourceCount() {
+ return sources.size();
+ }
+
/**
* Removes a previously-added property source.
*
@@ -448,6 +465,64 @@ public class Settings {
});
}
+ /**
+ * Returns the override value for the specified property, consulting
only the per-thread
+ * (local) and global stores — <i>not</i> the sources list.
+ *
+ * <p>
+ * Intended for callers (notably the {@code @Value} resolution path)
that interleave a
+ * caller-scoped property source between the Settings override stores
and the sources list.
+ * Using this method, the caller can honor the "{@link
#setLocal(String, String) local}
+ * / {@link #setGlobal(String, String) global} override wins" contract
before consulting
+ * its own scoped sources, and still fall through to {@link
#get(String)} (which checks
+ * the sources list) afterwards without the override stores
accidentally being consulted twice.
+ *
+ * <p>
+ * Lookup order honored by this method:
+ * <ol>
+ * <li>Per-thread (local) store
+ * <li>Global store
+ * </ol>
+ *
+ * <p>
+ * If a store is present with an empty value (i.e. an explicit null
override), the returned
+ * {@link Optional} is {@code Optional.empty()} but {@link
#isOverridden(String)} would
+ * return {@code true}. Callers needing to distinguish "absent" from
"explicit null" should
+ * use {@link #isOverridden(String)} alongside this method.
+ *
+ * @param name The property name. Must not be <jk>null</jk>.
+ * @return The override value, or {@link Optional#empty()} if neither
store carries an override.
+ * @see #isOverridden(String)
+ * @see #get(String)
+ */
+ public Optional<String> getOverride(String name) {
+ assertArgNotNull(ARG_name, name);
+ var v = localStore.get().get(name);
+ if (v.isPresent())
+ return v.value();
+ v = globalStore.get().get(name);
+ if (v.isPresent())
+ return v.value();
+ return Optional.empty();
+ }
+
+ /**
+ * Returns <jk>true</jk> if the specified property has an override in
either the per-thread
+ * (local) or global store.
+ *
+ * <p>
+ * Distinguishes the "explicit null override" case — where {@link
#getOverride(String)}
+ * returns {@link Optional#empty()} because the override value itself
was {@code null} —
+ * from the "no override" case.
+ *
+ * @param name The property name. Must not be <jk>null</jk>.
+ * @return <jk>true</jk> if either store carries an override (even with
a {@code null} value).
+ */
+ public boolean isOverridden(String name) {
+ assertArgNotNull(ARG_name, name);
+ return localStore.get().get(name).isPresent() ||
globalStore.get().get(name).isPresent();
+ }
+
/**
* Looks up a system property, returning a default value if not found.
*
diff --git
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/svl/vars/PropertyVar.java
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/svl/vars/PropertyVar.java
index 9aeba335b3..8dc9173ae3 100644
---
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/svl/vars/PropertyVar.java
+++
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/svl/vars/PropertyVar.java
@@ -24,6 +24,24 @@ import org.apache.juneau.commons.svl.*;
*
* <p>
* The format for this var is <js>"$P{propertyName[,defaultValue]}"</js>.
+ *
+ * <h5 class='section'>Caller-scoped property sources:</h5>
+ * <p>
+ * Resolution consults the singleton {@link Settings} chain by default.
Callers (notably the
+ * {@code @Value} injection path) that need to inject additional, caller-scoped
+ * {@link PropertySource}s in front of the global chain can attach them to the
+ * {@link VarResolverSession} as a session bean of type {@code
PropertySource[]}:
+ *
+ * <p class='bjava'>
+ * <jv>session</jv>.bean(PropertySource[].<jk>class</jk>, <jk>new</jk>
PropertySource[] { <jv>source1</jv>, <jv>source2</jv> });
+ * </p>
+ *
+ * <p>
+ * When the session carries that bean, this var consults the array in order
between the
+ * {@link Settings#getOverride(String) local/global override stores} (which
still win —
+ * matching the existing test-override contract) and the regular {@link
Settings#get(String)
+ * Settings sources chain}. Sources earlier in the array take precedence over
sources later
+ * in the array. The contract is identical to today when no such session bean
is attached.
*/
public class PropertyVar extends DefaultingVar {
@@ -39,6 +57,22 @@ public class PropertyVar extends DefaultingVar {
@Override /* Overridden from Var */
public String resolve(VarResolverSession session, String key) {
+ var scoped = session == null ? null :
session.getBean(PropertySource[].class).orElse(null);
+ if (scoped == null || scoped.length == 0)
+ return Settings.get().get(key).orElse(null);
+
+ // Caller-scoped sources are present. Honor Settings
local/global overrides first
+ // so test-override semantics still win over caller-scoped
sources, then walk the
+ // caller-scoped chain, then fall through to the global sources
list.
+ if (Settings.get().isOverridden(key))
+ return Settings.get().getOverride(key).orElse(null);
+ for (var src : scoped) {
+ if (src == null)
+ continue;
+ var r = src.get(key);
+ if (r.isPresent())
+ return r.value().orElse(null);
+ }
return Settings.get().get(key).orElse(null);
}
}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
index c5c0ee7dff..14d0d77fb5 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
@@ -63,6 +63,7 @@ import org.apache.juneau.commons.function.Memoizer;
import org.apache.juneau.commons.lang.*;
import org.apache.juneau.commons.logging.Logger;
import org.apache.juneau.commons.reflect.*;
+import org.apache.juneau.commons.settings.*;
import org.apache.juneau.commons.utils.*;
import org.apache.juneau.config.*;
import org.apache.juneau.config.vars.*;
@@ -726,6 +727,107 @@ public class RestContext extends Context {
// @formatter:on
}
+ /**
+ * Registers a {@link PropertySource} bean named {@code "rest.config"}
in this resource's
+ * {@code BeanStore} so that {@code @Value("${cfg-key}")} fields on the
resource bean (and on
+ * request-scoped beans whose {@code BeanStore} parent-walks to this
one) resolve against the
+ * resource's {@code @Rest(config=...)} {@link Config}(s).
+ *
+ * <p>
+ * Lookup order honored by the registered source:
+ * <ol>
+ * <li>The child class's resolved {@link Config} — sourced
from {@link #rawConfig} so that
+ * any {@link Bean @Bean Config} factory-method override
on the resource bean is preserved.
+ * <li>Any additional, distinct {@code @Rest(config=...)} {@code
Config}s declared on parent
+ * classes (child-to-parent order, dedup'd by resolved
file name). Child keys win on
+ * collision; parent keys fill the gaps.
+ * </ol>
+ *
+ * <p>
+ * Critically, this registration lives on the per-resource {@code
BeanStore} — <i>not</i> on
+ * the process-wide {@code Settings.get()} singleton. The static {@code
RestContext} cache in
+ * {@code MockRestClient} (and any other long-lived cache) is therefore
benign: leaked
+ * {@code RestContext}s carry their {@code PropertySource}s in their
own {@code BeanStore}s.
+ * The global {@code Settings} source list does not grow, and {@code
Settings.get(name)}'s
+ * reverse-order walk stays O(constant).
+ */
+ private void registerRestConfigPropertySources() {
+ var sources = collectRestConfigPropertySources();
+ if (sources.isEmpty())
+ return;
+ final List<PropertySource> chain = List.copyOf(sources);
+ PropertySource src;
+ if (chain.size() == 1) {
+ src = chain.get(0);
+ } else {
+ src = name -> {
+ for (var s : chain) {
+ var r = s.get(name);
+ if (r.isPresent())
+ return r;
+ }
+ return PropertyLookupResult.missing();
+ };
+ }
+ beanStore.addBean(PropertySource.class, src, "rest.config");
+ }
+
+ /**
+ * Walks the {@code @Rest(config=...)} annotation chain (child-first)
and builds a
+ * {@link ConfigPropertySource} for each distinct, non-empty config
attribute.
+ *
+ * <p>
+ * The first (most-derived) slot reuses {@code rawConfig.get()} so that
any {@link Bean @Bean}
+ * {@code Config} factory-method override on the resource bean is
preserved — the rest of
+ * the framework already sees that exact instance via {@link
#getConfig()} and {@code @RestInit}
+ * parameter resolution. Parent slots load via {@code
Config.create().name(...)} since {@code @Bean}
+ * overrides apply to the most-derived class only.
+ *
+ * <p>
+ * Edge case: a resource with no {@code @Rest(config=...)} annotation
but with a {@link Bean @Bean}
+ * {@code Config} factory method has a non-null {@code rawConfig.get()}
and no annotation-driven
+ * slot. That single {@code rawConfig} is still registered so
user-supplied {@code Config}s flow
+ * through to {@code @Value} resolution.
+ */
+ private List<PropertySource> collectRestConfigPropertySources() {
+ var bs = beanStore();
+ var vr = bs.getBean(VarResolver.class,
PROP_bootstrapVarResolver).orElseGet(this::getBootstrapVarResolver);
+ var result = new ArrayList<PropertySource>();
+ var seen = new LinkedHashSet<String>();
+ // AnnotationProvider returns child-first; iterate in the same
order so child wins on collision.
+ var anns = AnnotationProvider.INSTANCE.find(Rest.class,
info(resourceClass));
+ for (var i = 0; i < anns.size(); i++) {
+ var raw = anns.get(i).inner().config();
+ if (raw == null || raw.isEmpty())
+ continue;
+ var resolvedName = vr.resolve(raw);
+ if (resolvedName == null || resolvedName.isEmpty())
+ continue;
+ if (! seen.add(resolvedName))
+ continue;
+ Config cfg;
+ if (result.isEmpty()) {
+ // Most-derived child slot. rawConfig.get()
captures @Bean Config override; reuse it.
+ cfg = rawConfig.get();
+ } else if ("SYSTEM_DEFAULT".equals(resolvedName)) {
+ cfg = Config.getSystemDefault();
+ } else {
+ cfg =
Config.create().varResolver(vr).name(resolvedName).build();
+ }
+ if (cfg != null)
+ result.add(new ConfigPropertySource(cfg));
+ }
+ // Edge case: no @Rest(config=...) annotation produced a slot,
but rawConfig.get() is non-null
+ // (e.g. a @Bean Config factory method without a matching
@Rest(config=...) attribute, or the
+ // SYSTEM_DEFAULT branch fired without an annotation). Still
register rawConfig as the sole source.
+ if (result.isEmpty()) {
+ var rc = rawConfig.get();
+ if (rc != null)
+ result.add(new ConfigPropertySource(rc));
+ }
+ return result;
+ }
+
private static final class LifecycleInvokerPair {
final MethodList methods;
final MethodInvoker[] invokers;
@@ -1846,11 +1948,12 @@ public class RestContext extends Context {
// runtime Config) — @RestInit hooks that take Config
as a parameter will see the fully
// resolved instance instead of the raw bootstrap
Config (9.5 behavior change).
rawConfig.get();
- // NOTE: per-RestContext bridging of the
@Rest(config=...) Config into Settings.get()
- // was removed in 9.5 (perf regression — leaked
ConfigPropertySource instances into the
- // global Settings list because MockRestClient caches
RestContext instances statically
- // and never invokes destroy()). Per-RestContext
@Value("${cfg-key}") resolution
- // against the resource-scoped Config is tracked as
TODO-95 (BeanStore-routed lookup).
+ // Per-RestContext @Value resolution bridge: register
the @Rest(config=...) Configs as
+ // PropertySource beans inside THIS resource's
BeanStore (NOT on the process-wide
+ // Settings.get() singleton — that path was a 6.7x perf
regression because
+ // MockRestClient's static RestContext cache leaks
instances and Settings.get()'s source
+ // list grew unbounded). Per-resource isolation comes
for free from the BeanStore scope.
+ registerRestConfigPropertySources();
// Register memoizer-backed defaults for every
framework-managed type. These sit at the
// bottom of the precedence order and only fire when no
@Bean method, no programmatic
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/Settings_NoLeakedSources_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/Settings_NoLeakedSources_Test.java
new file mode 100644
index 0000000000..edbc3d0bed
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/Settings_NoLeakedSources_Test.java
@@ -0,0 +1,137 @@
+/*
+ * 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.rest;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.settings.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Regression test guarding the BeanStore-routed {@code @Rest(config=...)}
bridge against
+ * re-introducing the per-{@code RestContext} {@link Settings} source leak
that caused the
+ * 6.7× full-suite slowdown during an earlier prototype.
+ *
+ * <p>
+ * In FINISHED-79, an earlier draft of @Rest(config=...) bridging registered a
+ * {@code ConfigPropertySource} per resolved RestContext via {@code
Settings.get().addSource(...)}.
+ * MockRestClient statically caches RestContext instances and never invokes
{@code destroy()},
+ * so the global {@code Settings} source list grew unbounded across the test
suite — a 6.7x
+ * wall-clock regression as {@code Settings.get(name)}'s reverse-walk got O(N).
+ *
+ * <p>
+ * This test asserts that:
+ * <ol>
+ * <li>Building a single @Rest(config=...) MockRestClient adds zero new
sources to
+ * {@code Settings.get()}.
+ * <li>Building hundreds of mixed @Rest / @Rest(config=...)
MockRestClients leaves the source
+ * count unchanged.
+ * </ol>
+ *
+ * <p>
+ * If a future change accidentally routes @Rest(config=...) Configs through
+ * {@code Settings.get().addSource(...)} (or any other process-wide register),
this test fails
+ * immediately, well before the performance regression shows up in wall-clock
test time.
+ */
+@SuppressWarnings({
+ "serial" // BasicRestServlet is Serializable; not relevant in test
fixtures.
+})
+class Settings_NoLeakedSources_Test extends TestBase {
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Distinct @Rest resources — mixed config=/no-config — to exercise
both code paths.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest public static class NoConfig01 extends BasicRestServlet {}
+ @Rest public static class NoConfig02 extends BasicRestServlet {}
+ @Rest public static class NoConfig03 extends BasicRestServlet {}
+ @Rest public static class NoConfig04 extends BasicRestServlet {}
+ @Rest public static class NoConfig05 extends BasicRestServlet {}
+ @Rest public static class NoConfig06 extends BasicRestServlet {}
+ @Rest public static class NoConfig07 extends BasicRestServlet {}
+ @Rest public static class NoConfig08 extends BasicRestServlet {}
+ @Rest public static class NoConfig09 extends BasicRestServlet {}
+ @Rest public static class NoConfig10 extends BasicRestServlet {}
+
+ // We deliberately use a config name that points at a non-existent
file. The
+ // FileStore.DEFAULT-backed Config still builds (the in-memory
ConfigMap is empty) and the
+ // bridge still registers a "rest.config" PropertySource in the
RestContext's BeanStore —
+ // exactly the path we want to exercise without polluting the test cwd
with fixture files.
+ @Rest(config="settings_noleakedsources_test_a.cfg") public static class
WithConfig01 extends BasicRestServlet {}
+ @Rest(config="settings_noleakedsources_test_b.cfg") public static class
WithConfig02 extends BasicRestServlet {}
+ @Rest(config="settings_noleakedsources_test_c.cfg") public static class
WithConfig03 extends BasicRestServlet {}
+ @Rest(config="settings_noleakedsources_test_d.cfg") public static class
WithConfig04 extends BasicRestServlet {}
+ @Rest(config="settings_noleakedsources_test_e.cfg") public static class
WithConfig05 extends BasicRestServlet {}
+ @Rest(config="settings_noleakedsources_test_f.cfg") public static class
WithConfig06 extends BasicRestServlet {}
+ @Rest(config="settings_noleakedsources_test_g.cfg") public static class
WithConfig07 extends BasicRestServlet {}
+ @Rest(config="settings_noleakedsources_test_h.cfg") public static class
WithConfig08 extends BasicRestServlet {}
+ @Rest(config="settings_noleakedsources_test_i.cfg") public static class
WithConfig09 extends BasicRestServlet {}
+ @Rest(config="settings_noleakedsources_test_j.cfg") public static class
WithConfig10 extends BasicRestServlet {}
+
+ private static final Class<?>[] NO_CONFIG_CLASSES = {
+ NoConfig01.class, NoConfig02.class, NoConfig03.class,
NoConfig04.class, NoConfig05.class,
+ NoConfig06.class, NoConfig07.class, NoConfig08.class,
NoConfig09.class, NoConfig10.class,
+ };
+
+ private static final Class<?>[] WITH_CONFIG_CLASSES = {
+ WithConfig01.class, WithConfig02.class, WithConfig03.class,
WithConfig04.class, WithConfig05.class,
+ WithConfig06.class, WithConfig07.class, WithConfig08.class,
WithConfig09.class, WithConfig10.class,
+ };
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Acceptance — single instantiation adds zero new sources.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_singleInstantiation_zeroNewSources() {
+ var before = Settings.get().sourceCount();
+ MockRestClient.build(WithConfig01.class);
+ var after = Settings.get().sourceCount();
+ assertEquals(before, after,
+ "@Rest(config=...) must not leak ConfigPropertySource
instances into Settings.get().");
+ }
+
+ @Test void a02_singleInstantiation_noConfig_zeroNewSources() {
+ var before = Settings.get().sourceCount();
+ MockRestClient.build(NoConfig01.class);
+ var after = Settings.get().sourceCount();
+ assertEquals(before, after,
+ "@Rest (no config=) must not add any new Settings
sources.");
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Acceptance — 200 instantiations across mixed classes leave the
source count unchanged.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Test void a03_twoHundredInstantiations_zeroNewSources() {
+ var before = Settings.get().sourceCount();
+ // 200 builds = 10 base classes × 10 builds × 2 mixes (config +
no-config) = matches the
+ // FINISHED-79 cache-saturation threshold that produced the
6.7x regression.
+ for (var i = 0; i < 10; i++) {
+ for (var c : NO_CONFIG_CLASSES)
+ MockRestClient.build(c);
+ for (var c : WITH_CONFIG_CLASSES)
+ MockRestClient.build(c);
+ }
+ var after = Settings.get().sourceCount();
+ assertEquals(before, after,
+ "After 200 @Rest builds, Settings.get() source list
must not grow (FINISHED-79 regression guard).");
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/annotation/RestContext_ValueAgainstConfig_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/annotation/RestContext_ValueAgainstConfig_Test.java
new file mode 100644
index 0000000000..377dbb0e0a
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/annotation/RestContext_ValueAgainstConfig_Test.java
@@ -0,0 +1,375 @@
+/*
+ * 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.rest.annotation;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.nio.charset.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.function.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.commons.settings.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Acceptance tests for per-{@code RestContext} {@code @Value} resolution
against
+ * {@code @Rest(config=...)} {@link org.apache.juneau.config.Config}s.
+ *
+ * <p>
+ * The test writes its config fixtures to the cwd in {@link BeforeAll
@BeforeAll} (because the
+ * default {@link org.apache.juneau.config.store.FileStore#DEFAULT
FileStore.DEFAULT} resolves
+ * names against the cwd; with Maven Surefire that is the module directory).
The cwd-resident
+ * files are removed in {@link AfterAll @AfterAll}. Names use a fixed {@code
todo95-} prefix so
+ * they are easy to spot and clean up by hand should a test crash partway
through.
+ *
+ * <p>
+ * Each resource class uses a unique name so {@code MockRestClient}'s static
{@code RestContext}
+ * cache does not return a stale {@code RestContext} from an unrelated test
class.
+ */
+@SuppressWarnings({
+ "serial" // Test resources extend BasicRestServlet which is
serializable; not relevant.
+})
+class RestContext_ValueAgainstConfig_Test extends TestBase {
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Fixture management — write cfg files to cwd before tests, delete
them after.
+
//------------------------------------------------------------------------------------------------------------------
+
+ private static final String CFG_A = "todo95-rcvac-a.cfg";
+ private static final String CFG_B = "todo95-rcvac-b.cfg";
+ private static final String CFG_PARENT = "todo95-rcvac-parent.cfg";
+ private static final String CFG_CHILD = "todo95-rcvac-child.cfg";
+ private static final String CFG_OVERRIDE = "todo95-rcvac-override.cfg";
+ private static final String CFG_ASYNC = "todo95-rcvac-async.cfg";
+
+ @BeforeAll
+ static void writeFixtures() throws IOException {
+ write(CFG_A,
+ "api.key = secret-A",
+ "api.url = https://a.example.org/",
+ "foo = A",
+ "[section]",
+ "nested = nested-A");
+ write(CFG_B,
+ "api.key = secret-B",
+ "foo = B");
+ write(CFG_PARENT,
+ "parent.only = parent-value",
+ "shared = parent-shared");
+ write(CFG_CHILD,
+ "child.only = child-value",
+ "shared = child-shared");
+ write(CFG_OVERRIDE,
+ "override.key = from-config");
+ write(CFG_ASYNC,
+ "async.greeting = hello-from-config");
+ }
+
+ @AfterAll
+ static void removeFixtures() throws IOException {
+ for (var n : List.of(CFG_A, CFG_B, CFG_PARENT, CFG_CHILD,
CFG_OVERRIDE, CFG_ASYNC))
+ Files.deleteIfExists(Path.of(n));
+ }
+
+ private static void write(String name, String... lines) throws
IOException {
+ Files.writeString(Path.of(name),
String.join(System.lineSeparator(), lines) + System.lineSeparator(),
+ StandardCharsets.UTF_8);
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Acceptance #1 — Happy path. @Rest(config=...) + @Value("${cfg-key}")
resolves to the Config value.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest(config=CFG_A)
+ public static class HappyPathResource extends BasicRestServlet {
+ @Value("${api.key}")
+ String apiKey;
+
+ @Value("${api.url}")
+ String apiUrl;
+
+ @Value("${section/nested}")
+ String nested;
+ }
+
+ @Test void a01_happyPath_configKeyResolves() throws Exception {
+ var rc = build(HappyPathResource.class);
+ var bean = (HappyPathResource) rc.getResource();
+ assertEquals("secret-A", bean.apiKey);
+ assertEquals("https://a.example.org/", bean.apiUrl);
+ assertEquals("nested-A", bean.nested);
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Acceptance #2 — Fall-through. Key absent from Config falls back to
Settings/env.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest(config=CFG_A)
+ public static class FallThroughResource extends BasicRestServlet {
+ // "api.key" is in the Config; "todo95.absent.key" is not —
should hit the default branch.
+ @Value("${todo95.absent.key:default-fallback}")
+ String absentDefault;
+
+ // Use a Settings.setGlobal key during the test to prove
Settings sources still resolve.
+ @Value("${todo95.absent.from.settings}")
+ String fromSettings;
+ }
+
+ @Test void a02_fallThrough_defaultBranch_andSettings() throws Exception
{
+ Settings.get().setGlobal("todo95.absent.from.settings",
"from-settings");
+ try {
+ var rc = build(FallThroughResource.class);
+ var bean = (FallThroughResource) rc.getResource();
+ assertEquals("default-fallback", bean.absentDefault);
+ assertEquals("from-settings", bean.fromSettings);
+ } finally {
+
Settings.get().unsetGlobal("todo95.absent.from.settings");
+ }
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Acceptance #3 — Resource isolation. Resource A and Resource B both
have key "foo" but with
+ // different values; each sees its own.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest(config=CFG_A)
+ public static class IsolationResourceA extends BasicRestServlet {
+ @Value("${foo}")
+ String foo;
+ }
+
+ @Rest(config=CFG_B)
+ public static class IsolationResourceB extends BasicRestServlet {
+ @Value("${foo}")
+ String foo;
+ }
+
+ @Test void a03_resourceIsolation_each_sees_own_config() throws
Exception {
+ var rcA = build(IsolationResourceA.class);
+ var rcB = build(IsolationResourceB.class);
+ assertEquals("A", ((IsolationResourceA) rcA.getResource()).foo);
+ assertEquals("B", ((IsolationResourceB) rcB.getResource()).foo);
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Acceptance #4 — No-config resource. @Rest with no config attribute
resolves via Settings only.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest
+ public static class NoConfigResource extends BasicRestServlet {
+ @Value("${todo95.noconfig.key:noconfig-default}")
+ String value;
+
+ @Value("${todo95.noconfig.fromSettings}")
+ String fromSettings;
+ }
+
+ @Test void a04_noConfig_falls_through_to_settings_only() throws
Exception {
+ Settings.get().setGlobal("todo95.noconfig.fromSettings",
"settings-value");
+ try {
+ var rc = build(NoConfigResource.class);
+ var bean = (NoConfigResource) rc.getResource();
+ assertEquals("noconfig-default", bean.value);
+ assertEquals("settings-value", bean.fromSettings);
+ } finally {
+
Settings.get().unsetGlobal("todo95.noconfig.fromSettings");
+ }
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Acceptance #5 — Inheritance. Parent + child @Rest(config=...)
annotations: child wins on
+ // collision, parent fills the gaps.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest(config=CFG_PARENT)
+ public static class InheritanceParent extends BasicRestServlet {
+ @Value("${shared}")
+ String shared;
+
+ @Value("${parent.only}")
+ String parentOnly;
+
+ @Value("${child.only:absent-on-parent}")
+ String childOnly;
+ }
+
+ @Rest(config=CFG_CHILD)
+ public static class InheritanceChild extends InheritanceParent {}
+
+ @Test void a05_inheritance_child_wins_parent_fills_gaps() throws
Exception {
+ var rc = build(InheritanceChild.class);
+ var bean = (InheritanceChild) rc.getResource();
+ // Child wins on collision: shared resolves to child's value.
+ assertEquals("child-shared", bean.shared);
+ // Parent fills the gap: parent.only is only in parent.cfg,
child sees it.
+ assertEquals("parent-value", bean.parentOnly);
+ // Child has its own key:
+ assertEquals("child-value", bean.childOnly);
+ }
+
+ @Test void a05b_parent_alone_resolves_parent_keys() throws Exception {
+ var rc = build(InheritanceParent.class);
+ var bean = (InheritanceParent) rc.getResource();
+ assertEquals("parent-shared", bean.shared);
+ assertEquals("parent-value", bean.parentOnly);
+ // child.only is absent from parent.cfg → falls through to the
default branch.
+ assertEquals("absent-on-parent", bean.childOnly);
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Acceptance #6 — Settings.setLocal()/setGlobal() overrides still win
over resource Config.
+
//------------------------------------------------------------------------------------------------------------------
+
+ // MockRestClient caches RestContext per class, so a @Value String
field is captured ONCE at
+ // resource-bean construction time. To exercise the "override wins
later" path we use a
+ // Supplier<String> @Value — it re-evaluates on each .get() call
against a fresh
+ // VarResolverSession that carries the resource's BeanStore as a
session bean.
+ @Rest(config=CFG_OVERRIDE)
+ public static class OverrideResource extends BasicRestServlet {
+ @Value("${override.key}")
+ Supplier<String> value;
+ }
+
+ @Test void a06_setGlobal_overrides_resource_config() throws Exception {
+ var rc = build(OverrideResource.class);
+ var bean = (OverrideResource) rc.getResource();
+ // No override active → resource Config value wins.
+ assertEquals("from-config", bean.value.get());
+
+ Settings.get().setGlobal("override.key",
"from-settings-global");
+ try {
+ // Override active → setGlobal must beat resource
Config (per OQA #3).
+ assertEquals("from-settings-global", bean.value.get(),
+ "Settings.setGlobal must win over resource
@Rest(config) values.");
+ } finally {
+ Settings.get().unsetGlobal("override.key");
+ }
+ // Override gone → resource Config value visible again
(Supplier re-evaluates).
+ assertEquals("from-config", bean.value.get());
+ }
+
+ @Test void a06b_setLocal_overrides_resource_config() throws Exception {
+ var rc = build(OverrideResource.class);
+ var bean = (OverrideResource) rc.getResource();
+
+ Settings.get().setLocal("override.key", "from-settings-local");
+ try {
+ assertEquals("from-settings-local", bean.value.get(),
+ "Settings.setLocal must win over resource
@Rest(config) values.");
+ } finally {
+ Settings.get().unsetLocal("override.key");
+ }
+ assertEquals("from-config", bean.value.get());
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Acceptance #7 — Async / virtual-thread composition.
CompletableFuture<String> @RestOp method
+ // body reads a @Value field; assertion holds whether the future
resolves on the dispatch thread
+ // or a worker thread.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest(config=CFG_ASYNC)
+ public static class AsyncResource extends BasicRestServlet {
+ @Value("${async.greeting}")
+ String greeting;
+
+ @Value("${async.greeting}")
+ Supplier<String> greetingSupplier;
+ }
+
+ @Test void a07_async_composition_value_visible() throws Exception {
+ var rc = build(AsyncResource.class);
+ var bean = (AsyncResource) rc.getResource();
+ // The field value is captured at injection time — verify
directly on the bean.
+ assertEquals("hello-from-config", bean.greeting);
+ // The Supplier<String> re-evaluates on every .get() call.
+ assertEquals("hello-from-config", bean.greetingSupplier.get());
+
+ // Re-evaluation from a worker thread (CompletableFuture) must
see the same value because
+ // the BeanStore reference captured by the Supplier
participates regardless of which thread
+ // calls .get() — i.e., the resolution is not pinned to the
dispatch thread.
+ var worker =
CompletableFuture.supplyAsync(bean.greetingSupplier::get).get(5,
TimeUnit.SECONDS);
+ assertEquals("hello-from-config", worker);
+ }
+
+ @Rest(config=CFG_ASYNC, virtualThreads="true")
+ public static class AsyncVirtualThreadsResource extends
BasicRestServlet {
+ @Value("${async.greeting}")
+ String greeting;
+
+ @Value("${async.greeting}")
+ Supplier<String> greetingSupplier;
+ }
+
+ @Test void a07b_virtualThreads_value_visible() throws Exception {
+ var rc = build(AsyncVirtualThreadsResource.class);
+ var bean = (AsyncVirtualThreadsResource) rc.getResource();
+ // Field injection captured at construction time (the BeanStore
had the rest.config source
+ // in place by then, so this verifies the construction-time
path holds when the resource is
+ // configured with virtualThreads="true").
+ assertEquals("hello-from-config", bean.greeting);
+ // Re-evaluation from a worker thread continues to see the
value (BeanStore-by-reference
+ // capture in ValueResolver's supplier path, not pinned to the
dispatch thread).
+ var worker =
CompletableFuture.supplyAsync(bean.greetingSupplier::get).get(5,
TimeUnit.SECONDS);
+ assertEquals("hello-from-config", worker);
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Acceptance #8 — Registration is under name "rest.config" in the
resource BeanStore.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Test void a08_propertySource_registered_under_rest_config_name()
throws Exception {
+ var rc = build(HappyPathResource.class);
+ var src = rc.getBeanStore().getBean(PropertySource.class,
"rest.config").orElse(null);
+ assertNotNull(src, "Expected a PropertySource bean registered
as \"rest.config\" in the resource BeanStore.");
+ assertEquals("secret-A",
src.get("api.key").value().orElse(null));
+ }
+
+ @Test void a08b_no_config_no_registration() throws Exception {
+ // A @Rest resource with no config= attribute and no @Bean
Config method should leave the
+ // "rest.config" slot empty (rawConfig.get() returns an empty
Config that the bridge skips).
+ var rc = build(NoConfigResource.class);
+ var src = rc.getBeanStore().getBean(PropertySource.class,
"rest.config").orElse(null);
+ // An empty Config still has the bridge registered (since
rawConfig.get() is non-null), but
+ // querying any unknown key yields missing. Either no bean
registered OR a bean that returns
+ // missing for an arbitrary key is acceptable; assert the
latter.
+ if (src != null)
+ assertFalse(src.get("definitely-not-here").isPresent());
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Helper — build a RestContext and look it up in the global registry
so the test can read
+ // back the resource bean and the BeanStore directly.
+
//------------------------------------------------------------------------------------------------------------------
+
+ private static RestContext build(Class<?> resourceClass) throws
Exception {
+ // MockRestClient.build() instantiates a RestContext for the
resource class (and caches it
+ // statically); look the result up in the global registry to
read the injected fields.
+ MockRestClient.build(resourceClass);
+ var rc = RestContext.getGlobalRegistry().get(resourceClass);
+ assertNotNull(rc, "RestContext for " +
resourceClass.getSimpleName() + " not in REGISTRY after build.");
+ return rc;
+ }
+}