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 9be7cecfcf feat: dynamic child REST resources + inject-aware
microservice
9be7cecfcf is described below
commit 9be7cecfcf79059442047b51a3a9fdc93e5079a3
Author: James Bognar <[email protected]>
AuthorDate: Fri May 15 10:44:40 2026 -0400
feat: dynamic child REST resources + inject-aware microservice
---
.../apache/juneau/microservice/Microservice.java | 135 +++++-
.../microservice/jetty/JettyMicroservice.java | 128 ++++-
.../apache/juneau/ng/rest/client/NgRestClient.java | 2 +-
.../java/org/apache/juneau/rest/RestChildren.java | 329 ++++++++++++-
.../java/org/apache/juneau/rest/RestContext.java | 53 ++-
.../org/apache/juneau/rest/arg/DefaultArg.java | 3 +
.../juneau/rest/servlet/BasicRestObjectGroup.java | 93 +++-
.../juneau/rest/servlet/BasicRestServletGroup.java | 94 +++-
.../microservice/Microservice_Inject_Test.java | 235 ++++++++++
.../jetty/JettyMicroservice_Inject_Test.java | 252 ++++++++++
...BasicRestServletGroup_DynamicChildren_Test.java | 515 +++++++++++++++++++++
...d => FINISHED-11a-restclient-ng-design-plan.md} | 6 +-
todo/FINISHED-31-inject-aware-microservice.md | 159 +++++++
todo/FINISHED-33-dynamic-rest-children.md | 65 +++
todo/TODO-11-restclient-ng-coverage-closeout.md | 70 +++
todo/TODO.md | 4 +-
16 files changed, 2069 insertions(+), 74 deletions(-)
diff --git
a/juneau-microservice/juneau-microservice-core/src/main/java/org/apache/juneau/microservice/Microservice.java
b/juneau-microservice/juneau-microservice-core/src/main/java/org/apache/juneau/microservice/Microservice.java
index b20f9aa349..e8f099a1f3 100755
---
a/juneau-microservice/juneau-microservice-core/src/main/java/org/apache/juneau/microservice/Microservice.java
+++
b/juneau-microservice/juneau-microservice-core/src/main/java/org/apache/juneau/microservice/Microservice.java
@@ -32,6 +32,7 @@ import java.util.jar.*;
import java.util.logging.*;
import org.apache.juneau.collections.*;
+import org.apache.juneau.commons.inject.*;
import org.apache.juneau.commons.reflect.*;
import org.apache.juneau.commons.runtime.*;
import org.apache.juneau.commons.svl.*;
@@ -124,6 +125,8 @@ public class Microservice implements ConfigEventListener {
PrintWriter consoleWriter;
MicroserviceListener listener;
File workingDir =
env("juneau.workingDir").map(File::new).orElse(null);
+ WritableBeanStore beanStore;
+ List<Class<?>> configurations = list();
/**
* Constructor.
@@ -147,6 +150,8 @@ public class Microservice implements ConfigEventListener {
this.consoleReader = copyFrom.consoleReader;
this.consoleWriter = copyFrom.consoleWriter;
this.workingDir = copyFrom.workingDir;
+ this.beanStore = copyFrom.beanStore;
+ this.configurations = new
ArrayList<>(copyFrom.configurations);
}
/**
@@ -501,6 +506,67 @@ public class Microservice implements ConfigEventListener {
return this;
}
+ /**
+ * Registers one or more <c>@Configuration</c>-annotated
classes whose <c>@Bean</c> methods/fields
+ * will be processed during microservice bootstrap.
+ *
+ * <p>
+ * Beans contributed by <c>@Configuration</c> classes are
stored in the microservice's internal
+ * {@link WritableBeanStore} (accessible via {@link
Microservice#getBeanStore()}). They become
+ * available to other <c>@Bean</c> factory methods via
constructor injection.
+ *
+ * <p>
+ * Explicit builder calls (e.g. {@link #config(Config)}, {@link
#args(Args)}) take precedence over
+ * <c>@Bean</c>-supplied values — the resolved field is
registered into the bean store <i>after</i>
+ * configurations are processed, overwriting any same-type
<c>@Bean</c> contribution.
+ *
+ * @param configurations The configuration classes. Can be
<jk>null</jk> or empty.
+ * @return This object.
+ * @since 9.5.0
+ */
+ public Builder configurations(Class<?>... configurations) {
+ if (nn(configurations))
+ for (var c : configurations)
+ if (nn(c))
+ this.configurations.add(c);
+ return this;
+ }
+
+ /**
+ * Registers one or more <c>@Configuration</c>-annotated
classes.
+ *
+ * @param configurations The configuration classes. Can be
<jk>null</jk> or empty.
+ * @return This object.
+ * @see #configurations(Class...)
+ * @since 9.5.0
+ */
+ public Builder configurations(List<Class<?>> configurations) {
+ if (nn(configurations))
+ for (var c : configurations)
+ if (nn(c))
+ this.configurations.add(c);
+ return this;
+ }
+
+ /**
+ * Specifies an external {@link WritableBeanStore} to use
instead of constructing a fresh one.
+ *
+ * <p>
+ * Useful for testing and for composing a microservice into a
larger application that already owns
+ * a bean store (e.g. a Spring-backed parent store). When set,
configurations supplied via
+ * {@link #configurations(Class...)} are registered into this
store, and the microservice's
+ * resolved values (args, manifest, config, var resolver,
listener, microservice itself) are also
+ * registered into it.
+ *
+ * @param beanStore The external bean store. Can be
<jk>null</jk> to use a fresh internal store.
+ * @return This object.
+ * @since 9.5.0
+ */
+ public Builder beanStore(WritableBeanStore beanStore) {
+ this.beanStore = beanStore;
+ return this;
+ }
+
/**
* Resolves the specified path.
*
@@ -562,6 +628,7 @@ public class Microservice implements ConfigEventListener {
private final Thread consoleThread;
final File workingDir;
private final String configName;
+ private final WritableBeanStore beanStore;
private final AtomicReference<Logger> logger = new AtomicReference<>();
@@ -574,7 +641,8 @@ public class Microservice implements ConfigEventListener {
*/
@SuppressWarnings({
"resource", // Resources are managed by caller
- "java:S3776" // Cognitive complexity acceptable for
microservice initialization
+ "java:S3776", // Cognitive complexity acceptable for
microservice initialization
+ "java:S106" // Console fallback intentionally writes to
System.out when no Console is available.
})
protected Microservice(Builder builder) throws IOException,
ParseException {
setInstance(this);
@@ -582,12 +650,27 @@ public class Microservice implements ConfigEventListener {
this.workingDir = builder.workingDir;
this.configName = builder.configName;
- this.args = nn(builder.args) ? builder.args : new Args(new
String[0]);
+ //
--------------------------------------------------------------------------------
+ // Initialize the bean store and register any @Configuration
classes.
+ // @Bean-supplied values become candidate inputs for field
resolution below.
+ // Explicit builder calls always win and overwrite @Bean
contributions afterward.
+ //
--------------------------------------------------------------------------------
+ this.beanStore = nn(builder.beanStore) ? builder.beanStore :
new BasicBeanStore();
+ if (! builder.configurations.isEmpty())
+
beanStore.registerConfigurations(builder.configurations.toArray(new
Class<?>[0]));
+
+ // Resolve Args: builder > @Bean > empty.
+ this.args = nn(builder.args)
+ ? builder.args
+ : beanStore.getBean(Args.class).orElseGet(() -> new
Args(new String[0]));
+ beanStore.addBean(Args.class, this.args);
//
--------------------------------------------------------------------------------
// Try to get the manifest file if it wasn't already set.
//
--------------------------------------------------------------------------------
var manifest2 = builder.manifest;
+ if (manifest2 == null)
+ manifest2 =
beanStore.getBean(ManifestFile.class).orElse(null);
if (manifest2 == null) {
var m = new Manifest();
@@ -613,6 +696,7 @@ public class Microservice implements ConfigEventListener {
manifest2 = new ManifestFile(m);
}
this.manifest = manifest2;
+ beanStore.addBean(ManifestFile.class, this.manifest);
builder.varResolver
.vars(ArgsVar.create(() -> this.args))
.vars(ManifestFileVar.create(() -> this.manifest));
@@ -620,7 +704,7 @@ public class Microservice implements ConfigEventListener {
//
--------------------------------------------------------------------------------
// Try to resolve the configuration if not specified.
//
--------------------------------------------------------------------------------
- var config2 = builder.config;
+ var config2 = nn(builder.config) ? builder.config :
beanStore.getBean(Config.class).orElse(null);
var configBuilder =
builder.configBuilder.varResolver(builder.varResolver.build()).store(MemoryStore.DEFAULT);
if (config2 == null) {
var store = builder.configStore;
@@ -649,7 +733,9 @@ public class Microservice implements ConfigEventListener {
this.config = config2;
Config.setSystemDefault(this.config);
this.config.addListener(this);
+ beanStore.addBean(Config.class, this.config);
this.varResolver = builder.varResolver.bean(Config.class,
config2).build();
+ beanStore.addBean(VarResolver.class, this.varResolver);
//
--------------------------------------------------------------------------------
// Initialize console commands.
@@ -660,6 +746,9 @@ public class Microservice implements ConfigEventListener {
this.consoleReader =
firstNonNull(builder.consoleReader, new Scanner(c == null ? new
InputStreamReader(System.in) : c.reader()));
this.consoleWriter =
firstNonNull(builder.consoleWriter, c == null ? new PrintWriter(System.out,
true) : c.writer());
+ // @Bean-supplied console commands (registered first,
then overridable by builder/config below).
+ for (var cc :
beanStore.getBeansOfType(ConsoleCommand.class).values())
+ consoleCommandMap.put(cc.getName(), cc);
for (var cc : builder.consoleCommands) {
consoleCommandMap.put(cc.getName(), cc);
}
@@ -697,7 +786,14 @@ public class Microservice implements ConfigEventListener {
this.consoleWriter = null;
this.consoleThread = null;
}
- this.listener = nn(builder.listener) ? builder.listener : new
BasicMicroserviceListener();
+ // Resolve listener: builder > @Bean > default.
+ this.listener = nn(builder.listener)
+ ? builder.listener
+ :
beanStore.getBean(MicroserviceListener.class).orElseGet(BasicMicroserviceListener::new);
+ beanStore.addBean(MicroserviceListener.class, this.listener);
+
+ // Self-register so @Bean factory methods can take Microservice
as a constructor/method param.
+ beanStore.addBean(Microservice.class, this);
init();
}
@@ -907,6 +1003,29 @@ public class Microservice implements ConfigEventListener {
*/
public Logger getLogger() { return logger.get(); }
+ /**
+ * Returns the internal {@link WritableBeanStore} that backs this
microservice's inject-aware bootstrap.
+ *
+ * <p>
+ * The store is populated with this microservice's resolved values
(<c>Args</c>, <c>ManifestFile</c>,
+ * <c>Config</c>, <c>VarResolver</c>, <c>MicroserviceListener</c>, and
<c>Microservice</c> itself),
+ * plus any beans contributed by <c>@Configuration</c> classes
registered via
+ * {@link Builder#configurations(Class...)}.
+ *
+ * <p>
+ * Subclasses (e.g. <c>JettyMicroservice</c>) consult the store at
start-up to discover additional
+ * beans (servlets, listeners, factories) to register. External
callers can query the store directly
+ * to look up beans contributed by user-supplied <c>@Configuration</c>
classes.
+ *
+ * <p>
+ * The store is closed during {@link #stop()}, which triggers
<c>@PreDestroy</c> hooks on resolved
+ * beans.
+ *
+ * @return The microservice's bean store. Never <jk>null</jk>.
+ * @since 9.5.0
+ */
+ public WritableBeanStore getBeanStore() { return beanStore; }
+
/**
* Returns the main jar manifest file contents as a simple {@link
JsonMap}.
*
@@ -1130,6 +1249,14 @@ public class Microservice implements ConfigEventListener
{
})
public Microservice stop() throws Exception {
listener.onStop(this);
+ try {
+ beanStore.close();
+ } catch (Exception e) {
+ // @PreDestroy errors should not prevent the rest of
the shutdown sequence; surface via logger.
+ var lg = getLogger();
+ if (nn(lg))
+ lg.log(Level.WARNING, lm(e), e);
+ }
return this;
}
diff --git
a/juneau-microservice/juneau-microservice-jetty/src/main/java/org/apache/juneau/microservice/jetty/JettyMicroservice.java
b/juneau-microservice/juneau-microservice-jetty/src/main/java/org/apache/juneau/microservice/jetty/JettyMicroservice.java
index 92675f4862..b924f00754 100644
---
a/juneau-microservice/juneau-microservice-jetty/src/main/java/org/apache/juneau/microservice/jetty/JettyMicroservice.java
+++
b/juneau-microservice/juneau-microservice-jetty/src/main/java/org/apache/juneau/microservice/jetty/JettyMicroservice.java
@@ -31,6 +31,7 @@ import java.util.*;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.*;
+import org.apache.juneau.commons.inject.*;
import org.apache.juneau.commons.reflect.*;
import org.apache.juneau.commons.runtime.*;
import org.apache.juneau.commons.svl.*;
@@ -389,6 +390,24 @@ public class JettyMicroservice extends Microservice {
return this;
}
+ @Override /* Overridden from MicroserviceBuilder */
+ public Builder configurations(Class<?>...configurations) {
+ super.configurations(configurations);
+ return this;
+ }
+
+ @Override /* Overridden from MicroserviceBuilder */
+ public Builder configurations(List<Class<?>> configurations) {
+ super.configurations(configurations);
+ return this;
+ }
+
+ @Override /* Overridden from MicroserviceBuilder */
+ public Builder beanStore(WritableBeanStore beanStore) {
+ super.beanStore(beanStore);
+ return this;
+ }
+
@Override /* Overridden from MicroserviceBuilder */
public Builder workingDir(String path) {
super.workingDir(path);
@@ -483,12 +502,26 @@ public class JettyMicroservice extends Microservice {
* @throws IOException Problem occurred reading file.
* @throws ParseException Malformed content found in config file.
*/
+ @SuppressWarnings({
+ "resource" // getBeanStore() is microservice-owned and closed
in Microservice.stop(); fluent addBean() should not be auto-closed here.
+ })
protected JettyMicroservice(Builder builder) throws ParseException,
IOException {
super(builder);
setInstance(this);
this.builder = builder.copy();
- this.listener = nn(builder.listener2) ? builder.listener2 : new
BasicJettyMicroserviceListener();
- this.factory = nn(builder.factory) ? builder.factory : new
BasicJettyServerFactory();
+ var store = getBeanStore();
+ // Listener: explicit builder > @Bean (Jetty-specific first,
then core) > default.
+ this.listener = nn(builder.listener2)
+ ? builder.listener2
+ :
store.getBean(JettyMicroserviceListener.class).orElseGet(BasicJettyMicroserviceListener::new);
+ store.addBean(JettyMicroserviceListener.class, this.listener);
+ // Factory: explicit builder > @Bean > default.
+ this.factory = nn(builder.factory)
+ ? builder.factory
+ :
store.getBean(JettyServerFactory.class).orElseGet(BasicJettyServerFactory::new);
+ store.addBean(JettyServerFactory.class, this.factory);
+ // Self-register so @Bean factory methods can take
JettyMicroservice as a constructor/method param.
+ store.addBean(JettyMicroservice.class, this);
}
/**
@@ -554,7 +587,8 @@ public class JettyMicroservice extends Microservice {
* @throws ExecutableException Exception occurred on invoked
constructor/method/field.
*/
@SuppressWarnings({
- "java:S3776" // Cognitive complexity acceptable for server
creation logic
+ "java:S3776", // Cognitive complexity acceptable for server
creation logic
+ "resource" // getBeanStore() is owned by the microservice
lifecycle; do not close in createServer().
})
public Server createServer() throws ParseException, IOException,
ExecutableException {
listener.onCreateServer(this);
@@ -562,6 +596,7 @@ public class JettyMicroservice extends Microservice {
var cf = getConfig();
var mf = getManifest();
var vr = getVarResolver();
+ var store = getBeanStore();
var ports = firstNonNull(builder.ports,
cf.get("Jetty/port").as(int[].class).orElseGet(() ->
mf.get("Jetty-Port").map(JettyMicroservice::parseIntArray).orElseGet(() ->
ints(8000))));
var availablePort = findOpenPort(ports);
@@ -569,33 +604,45 @@ public class JettyMicroservice extends Microservice {
if (env("availablePort").isEmpty())
System.setProperty("availablePort",
String.valueOf(availablePort));
- var jettyXml = builder.jettyXml;
- var jettyConfig = cf.get("Jetty/config").orElseGet(() ->
mf.get("Jetty-Config").orElse("jetty.xml"));
- var resolveVars = firstNonNull(builder.jettyXmlResolveVars,
cf.get("Jetty/resolveVars").asBoolean().orElse(false));
- boolean resolveVars2 = isTrue(resolveVars);
+ // Prefer a @Bean-supplied Server if one was contributed via
@Configuration.
+ // Otherwise build from jetty.xml via the configured factory.
+ var injectedServer = store.getBean(Server.class).orElse(null);
+ if (nn(injectedServer)) {
+ server.set(injectedServer);
+ } else {
+ var jettyXml = builder.jettyXml;
+ var jettyConfig = cf.get("Jetty/config").orElseGet(()
-> mf.get("Jetty-Config").orElse("jetty.xml"));
+ var resolveVars =
firstNonNull(builder.jettyXmlResolveVars,
cf.get("Jetty/resolveVars").asBoolean().orElse(false));
+ boolean resolveVars2 = isTrue(resolveVars);
- if (jettyXml == null)
- jettyXml = loadSystemResourceAsString("jetty.xml", ".",
"files");
- if (jettyXml == null)
- throw rex("jetty.xml file ''{0}'' was not found on the
file system or classpath.", jettyConfig);
+ if (jettyXml == null)
+ jettyXml =
loadSystemResourceAsString("jetty.xml", ".", "files");
+ if (jettyXml == null)
+ throw rex("jetty.xml file ''{0}'' was not found
on the file system or classpath.", jettyConfig);
- if (resolveVars2)
- jettyXml = vr.resolve(jettyXml);
+ if (resolveVars2)
+ jettyXml = vr.resolve(jettyXml);
- getLogger().info(jettyXml);
+ getLogger().info(jettyXml);
- try {
- server.set(factory.create(jettyXml));
- } catch (Exception e2) {
- throw new ExecutableException(e2);
+ try {
+ server.set(factory.create(jettyXml));
+ } catch (Exception e2) {
+ throw new ExecutableException(e2);
+ }
}
+ // Publish the Server back to the store so @Bean factory
methods of other beans can depend on it.
+ store.addBean(Server.class, server.get());
+
+ // Track each servlet pathSpec with its declaring source so we
can fail loudly on collisions.
+ var mountedPaths = new LinkedHashMap<String,String>();
for (var s :
cf.get("Jetty/servlets").asStringArray().orElse(new String[0])) {
try {
var c = info(Class.forName(s));
if (c.isAssignableTo(RestServlet.class)) {
var rs = (RestServlet)c.newInstance();
- addServlet(rs, rs.getPath());
+ mountWithCollisionCheck(rs,
rs.getPath(), "Jetty/servlets[" + s + "]", mountedPaths);
} else {
throw rex("Invalid servlet specified in
Jetty/servlets. Must be a subclass of RestServlet: {0}", s);
}
@@ -609,7 +656,7 @@ public class JettyMicroservice extends Microservice {
var c = info(Class.forName(v.toString()));
if (c.isAssignableTo(Servlet.class)) {
var rs = (Servlet)c.newInstance();
- addServlet(rs, k);
+ mountWithCollisionCheck(rs, k,
"Jetty/servletMap[" + k + "]", mountedPaths);
} else {
throw rex("Invalid servlet specified in
Jetty/servletMap. Must be a subclass of Servlet: {0}", cn(v));
}
@@ -620,16 +667,55 @@ public class JettyMicroservice extends Microservice {
cf.get("Jetty/servletAttributes").asMap().orElse(EMPTY_MAP).forEach(this::addServletAttribute);
- builder.servlets.forEach((k, v) -> addServlet(v, k));
+ builder.servlets.forEach((k, v) -> mountWithCollisionCheck(v,
k, "Builder.servlet(" + cn(v) + ")", mountedPaths));
builder.servletAttributes.forEach(this::addServletAttribute);
+ // Auto-discover @Rest servlets contributed via
@Configuration/@Bean methods.
+ // Path source precedence: @Rest(path=...) on the resource
class, else "/".
+ // Builder-supplied servlets already won (registered above), so
duplicate mount paths fail hard.
+ for (var e : store.getBeansOfType(Servlet.class).entrySet()) {
+ var servlet = e.getValue();
+ var cls = servlet.getClass();
+ if (cls.getAnnotation(Rest.class) == null)
+ continue;
+ var pathSpec = restPathFor(cls);
+ mountWithCollisionCheck(servlet, pathSpec, "@Bean " +
cls.getName() + (ne(e.getKey()) ? "[" + e.getKey() + "]" : ""), mountedPaths);
+ }
+
if (env("juneau.serverPort").isEmpty())
System.setProperty("juneau.serverPort",
String.valueOf(availablePort));
return server.get();
}
+ private void mountWithCollisionCheck(Servlet servlet, String rawPath,
String source, Map<String,String> mountedPaths) {
+ var pathSpec = normalizePathSpec(rawPath);
+ var prior = mountedPaths.get(pathSpec);
+ if (nn(prior))
+ throw rex("Servlet mount path collision: ''{0}'' is
already mounted by {1}; refused by {2}.", pathSpec, prior, source);
+ mountedPaths.put(pathSpec, source);
+ addServlet(servlet, pathSpec);
+ }
+
+ private static String normalizePathSpec(String rawPath) {
+ var p = rawPath == null ? "" : rawPath;
+ if (p.isEmpty() || "/".equals(p))
+ return "/*";
+ if (! p.startsWith("/"))
+ p = "/" + p;
+ if (! p.endsWith("/*"))
+ p = trimTrailingSlashes(p) + "/*";
+ return p;
+ }
+
+ private static String restPathFor(Class<?> cls) {
+ var r = cls.getAnnotation(Rest.class);
+ if (r == null || r.path().isEmpty())
+ return "/";
+ return r.path();
+ }
+
/**
* Calls {@link Server#destroy()} on the underlying Jetty server if it
exists.
*
diff --git
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/NgRestClient.java
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/NgRestClient.java
index 48959fa26d..8a46a79be8 100644
---
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/NgRestClient.java
+++
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/NgRestClient.java
@@ -72,7 +72,7 @@ public final class NgRestClient implements Closeable {
* Call {@link Builder#bodyConverters(BodyConverter[])} to replace all
defaults.
*/
public static final List<BodyConverter<?>> DEFAULT_BODY_CONVERTERS =
List.of(
- BodyConverter.of(HttpBody.class, body ->
TransportBody.of(body)),
+ BodyConverter.of(HttpBody.class, TransportBody::of),
BodyConverter.of(InputStream.class, is ->
TransportBody.of(StreamBody.of(is))),
BodyConverter.of(byte[].class, bytes ->
TransportBody.of(ByteArrayBody.of(bytes))),
BodyConverter.of(java.io.File.class, file ->
TransportBody.of(FileBody.of(file)))
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestChildren.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestChildren.java
index 928ae30fb9..1be33ca18f 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestChildren.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestChildren.java
@@ -17,11 +17,15 @@
package org.apache.juneau.rest;
import static org.apache.juneau.commons.utils.CollectionUtils.*;
+import static org.apache.juneau.commons.utils.ThrowableUtils.*;
import static org.apache.juneau.commons.utils.Utils.*;
+import java.lang.reflect.*;
import java.util.*;
+import java.util.function.*;
-import org.apache.juneau.commons.inject.BeanStore;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.commons.reflect.*;
import org.apache.juneau.rest.annotation.*;
import org.apache.juneau.rest.util.*;
@@ -30,6 +34,14 @@ import jakarta.servlet.*;
/**
* Implements the child resources of a {@link Rest}-annotated class.
*
+ * <p>
+ * Holds a registry of {@link RestContext} instances keyed by {@link
RestContext#getPath() composed path}.
+ * Reads (request routing via {@link #findMatch(RestSession.Builder)} and the
{@link #asMap()} accessor) are
+ * lock-free against a volatile copy-on-write snapshot; mutations
+ * ({@link #addChild(Class) addChild} / {@link #removeChild(String)
removeChild}) synchronize on an internal
+ * write lock and atomically replace the snapshot. This makes runtime child
management safe even while
+ * requests are in flight on other threads.
+ *
* <h5 class='section'>See Also:</h5><ul>
* <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/RestAnnotatedClassBasics">@Rest-Annotated
Class Basics</a>
* </ul>
@@ -41,17 +53,27 @@ public class RestChildren {
*/
public static class Builder {
- private final BeanStore beanStore;
+ final RestContext parent;
+ final BeanStore beanStore;
+ final ServletConfig servletConfig;
final List<RestContext> list;
/**
* Constructor.
*
- * @param beanStore The bean store to use for creating beans.
+ * @param parent The parent {@link RestContext} that owns these
children. Used for parent-context wiring
+ * on dynamically added children. May be {@code null} when
constructing from contexts already built
+ * externally and runtime add/remove is not required.
+ * @param beanStore The bean store used for dependency
injection on dynamically added children. Must not be
+ * {@code null}.
+ * @param servletConfig The {@link ServletConfig} passed
through to dynamically added child contexts.
+ * May be {@code null}.
*/
- protected Builder(BeanStore beanStore) {
+ protected Builder(RestContext parent, BeanStore beanStore,
ServletConfig servletConfig) {
+ this.parent = parent;
this.beanStore = beanStore;
- list = list();
+ this.servletConfig = servletConfig;
+ this.list = list();
}
/**
@@ -87,14 +109,22 @@ public class RestChildren {
/**
* Static creator.
*
- * @param beanStore The bean store to use for creating beans.
+ * @param parent The parent {@link RestContext}. Required for runtime
{@link #addChild(Class) addChild} support
+ * (may be {@code null} otherwise).
+ * @param beanStore The bean store used for child instantiation.
+ * @param servletConfig The {@link ServletConfig} propagated to
dynamically added children. May be {@code null}.
* @return A new builder for this object.
*/
- public static Builder create(BeanStore beanStore) {
- return new Builder(beanStore);
+ public static Builder create(RestContext parent, BeanStore beanStore,
ServletConfig servletConfig) {
+ return new Builder(parent, beanStore, servletConfig);
}
- private final Map<String,RestContext> children = synced(map());
+ private final RestContext parent;
+ private final BeanStore beanStore;
+ private final ServletConfig servletConfig;
+ private final Object writeLock = new Object();
+
+ private volatile Map<String,RestContext> children;
/**
* Constructor.
@@ -102,31 +132,35 @@ public class RestChildren {
* @param builder The builder containing the settings for this object.
*/
public RestChildren(Builder builder) {
+ this.parent = builder.parent;
+ this.beanStore = builder.beanStore;
+ this.servletConfig = builder.servletConfig;
+ var initial = new LinkedHashMap<String,RestContext>();
for (var rc : builder.list)
- children.put(rc.getPath(), rc);
+ initial.put(rc.getPath(), rc);
+ this.children = Collections.unmodifiableMap(initial);
}
/**
* Returns the children in this object as a map.
*
* <p>
- * The keys are the {@link RestContext#getPath() paths} of the child
contexts.
+ * The keys are the {@link RestContext#getPath() paths} of the child
contexts. The returned map is an
+ * unmodifiable snapshot taken at the time of the call; subsequent
{@link #addChild(Class) addChild} /
+ * {@link #removeChild(String) removeChild} calls do not affect this
view.
*
* @return The children as an unmodifiable map.
*/
public Map<String,RestContext> asMap() {
- return u(children);
+ return children;
}
/**
* Called during servlet destruction on all children to invoke all
{@link RestDestroy} and {@link Servlet#destroy()} methods.
*/
public void destroy() {
- for (var r : children.values()) {
- r.destroy();
- if (r.getResource() instanceof Servlet r2)
- r2.destroy();
- }
+ for (var r : children.values())
+ destroyChild(r);
}
/**
@@ -136,9 +170,10 @@ public class RestChildren {
* @return The child that best matches the call, or an empty {@link
Optional} if a match could not be made.
*/
public Optional<RestChildMatch> findMatch(RestSession.Builder builder) {
+ var snapshot = children; // single volatile read; consistent
for the rest of the method
var pi = builder.getPathInfoUndecoded();
- if ((! children.isEmpty()) && nn(pi) && ! pi.equals("/")) {
- for (var rc : children.values()) {
+ if ((! snapshot.isEmpty()) && nn(pi) && ! pi.equals("/")) {
+ for (var rc : snapshot.values()) {
UrlPathMatcher upp = rc.getPathMatcher();
UrlPathMatch uppm =
upp.match(builder.getUrlPath());
if (nn(uppm)) {
@@ -168,4 +203,258 @@ public class RestChildren {
for (var childContext : children.values())
childContext.postInitChildFirst();
}
-}
\ No newline at end of file
+
+
//-------------------------------------------------------------------------------------------------------------
+ // Dynamic add/remove API.
+ //
+ // Routing reads (findMatch / asMap) are lock-free against the volatile
`children` snapshot.
+ // Mutations synchronize on writeLock and atomically swap in a new
unmodifiable LinkedHashMap.
+
//-------------------------------------------------------------------------------------------------------------
+
+ /**
+ * Dynamically registers a child REST resource by class, instantiating
it via the parent's {@link BeanStore}.
+ *
+ * <p>
+ * The path under which the child is registered is determined by the
{@link Rest#path() @Rest(path)} annotation
+ * on the resource class composed against the parent's full path.
+ *
+ * <p>
+ * The new child's {@link RestContext#postInit()} and {@link
RestContext#postInitChildFirst()} lifecycle hooks
+ * are invoked before this method returns, mirroring the eager-init
behavior of {@code @Rest(children = ...)}.
+ *
+ * @param resourceClass The {@code @Rest}-annotated resource class.
+ * @return The newly-built child {@link RestContext}.
+ * @throws ServletException If construction or lifecycle initialization
of the child fails.
+ * @throws IllegalStateException If a child is already registered at
the resolved path.
+ */
+ public RestContext addChild(Class<?> resourceClass) throws
ServletException {
+ return addChildInternal(resourceClass, null, "", false);
+ }
+
+ /**
+ * Dynamically registers a pre-instantiated child REST resource.
+ *
+ * <p>
+ * The path under which the child is registered is determined by the
{@link Rest#path() @Rest(path)} annotation
+ * on the resource's class composed against the parent's full path.
+ *
+ * @param resource The {@code @Rest}-annotated resource instance.
+ * @return The newly-built child {@link RestContext}.
+ * @throws ServletException If construction or lifecycle initialization
of the child fails.
+ * @throws IllegalStateException If a child is already registered at
the resolved path.
+ */
+ public RestContext addChild(Object resource) throws ServletException {
+ return addChildInternal(resource.getClass(), resource, "",
false);
+ }
+
+ /**
+ * Dynamically registers a pre-instantiated child REST resource,
optionally replacing any existing child at the
+ * same resolved path.
+ *
+ * @param resource The {@code @Rest}-annotated resource instance.
+ * @param replace If {@code true}, an existing child at the same path
is destroyed and removed before the new
+ * child is added. If {@code false}, an existing child causes an
{@link IllegalStateException}.
+ * @return The newly-built child {@link RestContext}.
+ * @throws ServletException If construction or lifecycle initialization
of the child fails.
+ */
+ public RestContext addChild(Object resource, boolean replace) throws
ServletException {
+ return addChildInternal(resource.getClass(), resource, "",
replace);
+ }
+
+ /**
+ * Dynamically registers a pre-instantiated child REST resource at an
explicit path.
+ *
+ * <p>
+ * The supplied {@code path} overrides whatever {@link Rest#path()
@Rest(path)} would normally provide. Useful
+ * for mounting the same servlet class at multiple paths, or for test
fixtures that compose paths programmatically.
+ *
+ * @param path The path segment under the parent at which to mount this
child. Leading slashes are trimmed.
+ * @param resource The {@code @Rest}-annotated resource instance.
+ * @return The newly-built child {@link RestContext}.
+ * @throws ServletException If construction or lifecycle initialization
of the child fails.
+ * @throws IllegalStateException If a child is already registered at
the resolved path.
+ */
+ public RestContext addChild(String path, Object resource) throws
ServletException {
+ return addChildInternal(resource.getClass(), resource, path,
false);
+ }
+
+ /**
+ * Dynamically registers a pre-instantiated child REST resource at an
explicit path, optionally replacing any
+ * existing child at the same path.
+ *
+ * @param path The path segment under the parent at which to mount this
child. Leading slashes are trimmed.
+ * @param resource The {@code @Rest}-annotated resource instance.
+ * @param replace If {@code true}, an existing child at the same path
is destroyed and removed before the new
+ * child is added. If {@code false}, an existing child causes an
{@link IllegalStateException}.
+ * @return The newly-built child {@link RestContext}.
+ * @throws ServletException If construction or lifecycle initialization
of the child fails.
+ */
+ public RestContext addChild(String path, Object resource, boolean
replace) throws ServletException {
+ return addChildInternal(resource.getClass(), resource, path,
replace);
+ }
+
+ /**
+ * Removes the child registered at the given composed path.
+ *
+ * <p>
+ * The removed child has {@link RestContext#destroy()} invoked (which
runs {@code @RestDestroy} hooks and
+ * recursively destroys any grandchildren), followed by {@link
Servlet#destroy()} if the underlying resource is
+ * a {@link Servlet}.
+ *
+ * @param path The composed path key (as returned by {@link
RestContext#getPath()}) of the child to remove.
+ * @return The removed and destroyed {@link RestContext}, or {@code
null} if no child was registered at the
+ * given path.
+ */
+ public RestContext removeChild(String path) {
+ RestContext removed;
+ synchronized (writeLock) {
+ removed = children.get(path);
+ if (removed == null)
+ return null;
+ var next = new LinkedHashMap<>(children);
+ next.remove(path);
+ children = Collections.unmodifiableMap(next);
+ }
+ destroyChild(removed);
+ return removed;
+ }
+
+ /**
+ * Removes the first child whose resource class matches the given type.
+ *
+ * @param resourceClass The resource class to match. Matching is
performed via {@link RestContext#getResourceClass()}.
+ * @return The removed and destroyed {@link RestContext}, or {@code
null} if no matching child was found.
+ */
+ public RestContext removeChild(Class<?> resourceClass) {
+ String key = null;
+ RestContext removed = null;
+ synchronized (writeLock) {
+ for (var e : children.entrySet()) {
+ if (e.getValue().getResourceClass() ==
resourceClass) {
+ key = e.getKey();
+ removed = e.getValue();
+ break;
+ }
+ }
+ if (removed == null)
+ return null;
+ var next = new LinkedHashMap<>(children);
+ next.remove(key);
+ children = Collections.unmodifiableMap(next);
+ }
+ destroyChild(removed);
+ return removed;
+ }
+
+
//-------------------------------------------------------------------------------------------------------------
+ // Internal helpers.
+
//-------------------------------------------------------------------------------------------------------------
+
+ /**
+ * Shared child-context construction recipe — used both by the eager
init memoizer in {@link RestContext} and by
+ * the runtime {@link #addChild(Class) addChild} family of methods.
+ *
+ * <p>
+ * Resolves a {@link Supplier} for the resource (prefers an existing
bean of the same class in the bean store,
+ * otherwise instantiates via {@link BeanInstantiator}), constructs the
child {@link RestContext} using the
+ * supplied {@code parent} / {@code servletConfig} / {@code
pathOverride}, and invokes any
+ * {@code setContext(RestContext)} method on the resource via
reflection (so that {@code RestServlet}-style
+ * resources receive their context handle).
+ *
+ * @param parent The parent {@link RestContext}. Must not be {@code
null}.
+ * @param beanStore The bean store to resolve instances and
dependencies from. Must not be {@code null}.
+ * @param servletConfig The {@link ServletConfig} to pass through to
the child. May be {@code null}.
+ * @param resourceClass The resource class. Must not be {@code null}.
+ * @param resourceInstance A pre-supplied instance of the resource. If
{@code null}, the instance is resolved
+ * from the bean store or freshly instantiated via {@link
BeanInstantiator}.
+ * @param pathOverride An explicit path segment (relative to the
parent). Use {@code ""} to read the path from
+ * {@link Rest#path() @Rest(path)} on the resource class.
+ * @return The newly-built child {@link RestContext}, with its {@code
setContext} method (if any) invoked.
+ * @throws Exception If construction or reflective wiring fails.
+ */
+ static RestContext buildChildContext(
+ RestContext parent,
+ BeanStore beanStore,
+ ServletConfig servletConfig,
+ Class<?> resourceClass,
+ Object resourceInstance,
+ String pathOverride) throws Exception {
+ Supplier<?> so;
+ if (resourceInstance != null) {
+ final Object r = resourceInstance;
+ so = () -> r;
+ } else if (beanStore.getBean(resourceClass).isPresent()) {
+ so = () -> beanStore.getBean(resourceClass).get();
+ } else {
+ Object o = BeanInstantiator.of(resourceClass,
beanStore).run();
+ so = () -> o;
+ }
+ var cc = new RestContext(new RestContext.Args(resourceClass,
parent, servletConfig, so, pathOverride, null));
+ var mi = ClassInfo.of(so.get())
+ .getMethod(x -> x.hasName("setContext") &&
x.hasParameterTypes(RestContext.class))
+ .orElse(null);
+ if (nn(mi))
+ mi.accessible().invoke(so.get(), cc);
+ return cc;
+ }
+
+ private RestContext addChildInternal(Class<?> resourceClass, Object
resourceInstance, String pathOverride, boolean replace) throws ServletException
{
+ if (parent == null || beanStore == null)
+ throw illegalState("Cannot add a child to a
RestChildren that was not initialized with a parent RestContext.");
+ RestContext cc;
+ try {
+ cc = buildChildContext(parent, beanStore,
servletConfig, resourceClass, resourceInstance, pathOverride);
+ } catch (Exception e) {
+ throw new ServletException("Failed to build child REST
context for " + resourceClass.getName(), unwrapThrowable(e));
+ }
+ var key = cc.getPath();
+ RestContext replaced = null;
+ synchronized (writeLock) {
+ if (children.containsKey(key)) {
+ if (! replace) {
+ destroyQuietly(cc);
+ throw illegalState("Child resource
already registered at path ''{0}''.", key);
+ }
+ replaced = children.get(key);
+ var withoutExisting = new
LinkedHashMap<>(children);
+ withoutExisting.remove(key);
+ // We don't publish the intermediate "without
existing" snapshot — single atomic swap below.
+ withoutExisting.put(key, cc);
+ children =
Collections.unmodifiableMap(withoutExisting);
+ } else {
+ var next = new LinkedHashMap<>(children);
+ next.put(key, cc);
+ children = Collections.unmodifiableMap(next);
+ }
+ }
+ if (replaced != null)
+ destroyChild(replaced);
+ cc.postInit();
+ cc.postInitChildFirst();
+ return cc;
+ }
+
+ private static void destroyChild(RestContext child) {
+ // Servlet-backed resources delegate Servlet.destroy() through
to RestContext.destroy() (see RestServlet.destroy),
+ // so we must avoid double-destruction in that case — call
Servlet.destroy() and let it transitively call
+ // RestContext.destroy() exactly once.
+ if (child.getResource() instanceof Servlet s)
+ s.destroy();
+ else
+ child.destroy();
+ }
+
+ private static void destroyQuietly(RestContext child) {
+ try {
+ destroyChild(child);
+ } catch (Exception ignored) {
+ // best-effort cleanup of a child we're about to throw
away
+ }
+ }
+
+ private static Throwable unwrapThrowable(Throwable t) {
+ if (t instanceof InvocationTargetException t2)
+ return t2.getTargetException();
+ return t;
+ }
+}
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 740f63647b..61f9406fdf 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
@@ -1115,7 +1115,7 @@ public class RestContext extends Context {
private final Memoizer<RestChildren> restChildren = memoizer(() ->
safe(() -> {
var bs = beanStore();
var servletConfig =
bs.getBean(ServletConfig.class).orElse(null);
- var b = RestChildren.create(bs);
+ var b = RestChildren.create(this, bs, servletConfig);
// Collect child classes from @Rest(children) on the annotation
chain (parent-to-child order).
// Deduplicate so the same child class registered on both a
parent and child annotation
@@ -1126,18 +1126,7 @@ public class RestContext extends Context {
for (var rc2 : seen) {
if (rc2 == resourceClass())
continue; // Guard against self-reference
infinite loop.
- Supplier<?> so;
- if (bs.getBean(rc2).isPresent()) {
- so = () -> bs.getBean(rc2).get();
- } else {
- Object o2 = BeanInstantiator.of(rc2, bs).run();
- so = () -> o2;
- }
- var cc = new RestContext(new Args(rc2, this,
servletConfig, so, "", null));
- var mi = ClassInfo.of(so.get()).getMethod(x ->
x.hasName("setContext") && x.hasParameterTypes(RestContext.class)).orElse(null);
- if (nn(mi))
- mi.accessible().invoke(so.get(), cc);
- b.add(cc);
+ b.add(RestChildren.buildChildContext(this, bs,
servletConfig, rc2, null, ""));
}
// @Bean override — allows replacing the entire RestChildren
instance.
@@ -1318,14 +1307,21 @@ public class RestContext extends Context {
// --- end beanStore setup ---
- // Path is read directly from the @Rest(path)
annotation chain (most-derived class wins),
- // replacing the prior Builder.path staging field
eliminated in the May 2026 refactor.
- path = getRestAnnotations().stream()
- .map(ai -> ai.inner().path())
- .filter(StringUtils::isNotEmpty)
- .findFirst()
- .map(s -> trimLeadingSlashes(s))
- .orElse("");
+ // Path resolution: explicit Args.path (non-empty) wins
so callers like
+ // RestChildren.addChild(String,Object) can mount the
same class at a custom path; otherwise read
+ // from the @Rest(path) annotation chain (most-derived
class wins). This restores override behavior
+ // removed in the May 2026 Builder.path elimination,
without resurrecting the staging field.
+ var argsPath = builder.args.path();
+ if (argsPath != null && ! argsPath.isEmpty()) {
+ path = trimLeadingSlashes(argsPath);
+ } else {
+ path = getRestAnnotations().stream()
+ .map(ai -> ai.inner().path())
+ .filter(StringUtils::isNotEmpty)
+ .findFirst()
+ .map(s -> trimLeadingSlashes(s))
+ .orElse("");
+ }
fullPath = (parentContext == null ? "" :
(parentContext.fullPath + '/')) + path;
var p = path;
if (! p.endsWith("/*"))
@@ -1754,6 +1750,11 @@ public class RestContext extends Context {
* Called during servlet destruction to invoke all {@link RestDestroy}
methods.
*/
public void destroy() {
+ // @RestDestroy hooks first, then children (recursive cleanup),
then close this context's bean store.
+ // Destroying children before closing the bean store is
required: getRestChildren() reads from the bean
+ // store and BasicBeanStore.getBean() rejects calls after
close(), so the previous post-close lookup was
+ // a latent bug that only surfaced when destroy() was invoked
outside the JVM-shutdown happy path
+ // (e.g. dynamic RestChildren.removeChild — see TODO-33).
for (var x : destroyInvokerPair.get().invokers) {
try {
x.invoke(beanStore, getResource());
@@ -1761,13 +1762,14 @@ public class RestContext extends Context {
getLogger().log(Level.WARNING, unwrap(e), () ->
f("Error occurred invoking servlet-destroy method ''{0}''.", x.getFullName()));
}
}
+ var childrenRef = getRestChildren();
+ if (nn(childrenRef))
+ childrenRef.destroy();
try {
beanStore.close();
} catch (Exception e) {
getLogger().log(Level.WARNING, unwrap(e), () -> "Error
occurred closing bean store.");
}
-
- getRestChildren().destroy();
}
/**
@@ -2520,7 +2522,10 @@ public class RestContext extends Context {
if (initialized.get())
return this;
var resource2 = getResource();
- var mi = ClassInfo.of(getResource()).getPublicMethod(x ->
x.hasName("setContext") && x.hasParameterTypes(RestContext.class)).orElse(null);
+ // Use getMethod (not getPublicMethod) to match the child-init
memoizer at RestContext.restChildren — covers
+ // the protected `setContext` declared on `RestServlet` /
`RestObject` so external callers (e.g. MockRestClient,
+ // the @RestInit / lifecycle wiring) can hand the resource its
RestContext post-construction.
+ var mi = ClassInfo.of(getResource()).getMethod(x ->
x.hasName("setContext") && x.hasParameterTypes(RestContext.class)).orElse(null);
if (nn(mi)) {
try {
mi.accessible().invoke(resource2, this);
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/arg/DefaultArg.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/arg/DefaultArg.java
index 7c8e20daf3..abb993f22c 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/arg/DefaultArg.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/arg/DefaultArg.java
@@ -70,6 +70,9 @@ public class DefaultArg implements RestOpArg {
}
@Override /* Overridden from RestOpArg */
+ @SuppressWarnings({
+ "resource" // Bean values may be AutoCloseable but lifecycle is
owned by the container, not this resolver.
+ })
public Object resolve(RestOpSession opSession) throws Exception {
return opSession.getBeanStore().getBean(type,
qualifier).orElseThrow(() -> new ArgException(paramInfo, "Could not resolve
bean type {0}", cn(type)));
}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestObjectGroup.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestObjectGroup.java
index a57783d94b..f6c394a9c8 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestObjectGroup.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestObjectGroup.java
@@ -21,6 +21,7 @@ import org.apache.juneau.rest.annotation.*;
import org.apache.juneau.rest.beans.*;
import org.apache.juneau.rest.config.*;
+import jakarta.servlet.*;
import jakarta.servlet.http.*;
/**
@@ -39,6 +40,14 @@ import jakarta.servlet.http.*;
* <p>
* Children are attached to this resource using the {@link Rest#children()
@Rest(children)} annotation.
*
+ * <h5 class='section'>Dynamic children:</h5>
+ *
+ * <p>
+ * Additional child resources can be registered or unregistered at runtime via
{@link #addChild(Class)} /
+ * {@link #removeChild(String)} (and overloads). This is particularly useful
in test fixtures where a single
+ * group resource is mounted once and individual tests plug in their own
{@code @Rest} resources without restarting
+ * the container.
+ *
* <h5 class='section'>See Also:</h5><ul>
* <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/RestAnnotatedClassBasics">@Rest-Annotated
Class Basics</a>
* </ul>
@@ -50,4 +59,86 @@ public abstract class BasicRestObjectGroup extends
BasicRestObject implements Ba
public ChildResourceDescriptions getChildren(RestRequest req) {
return ChildResourceDescriptions.of(req);
}
-}
\ No newline at end of file
+
+ /**
+ * Returns the {@link RestChildren} registry backing this group
resource.
+ *
+ * <p>
+ * Use this for full access to the dynamic add/remove API, including
the {@code replace} overloads and the
+ * unmodifiable {@link RestChildren#asMap() asMap()} snapshot.
+ *
+ * @return The {@link RestChildren} registry for this resource's {@link
RestContext}.
+ */
+ public RestChildren getChildResources() {
+ return getContext().getRestChildren();
+ }
+
+ /**
+ * Dynamically registers a child REST resource by class.
+ *
+ * <p>
+ * Convenience pass-through to {@link RestChildren#addChild(Class)} on
{@link #getChildResources()}.
+ *
+ * @param resourceClass The {@code @Rest}-annotated resource class.
+ * @return The newly-built child {@link RestContext}.
+ * @throws ServletException If construction or lifecycle initialization
of the child fails.
+ */
+ public RestContext addChild(Class<?> resourceClass) throws
ServletException {
+ return getChildResources().addChild(resourceClass);
+ }
+
+ /**
+ * Dynamically registers a pre-instantiated child REST resource.
+ *
+ * <p>
+ * Convenience pass-through to {@link RestChildren#addChild(Object)} on
{@link #getChildResources()}.
+ *
+ * @param resource The {@code @Rest}-annotated resource instance.
+ * @return The newly-built child {@link RestContext}.
+ * @throws ServletException If construction or lifecycle initialization
of the child fails.
+ */
+ public RestContext addChild(Object resource) throws ServletException {
+ return getChildResources().addChild(resource);
+ }
+
+ /**
+ * Dynamically registers a pre-instantiated child REST resource at an
explicit path.
+ *
+ * <p>
+ * Convenience pass-through to {@link RestChildren#addChild(String,
Object)} on {@link #getChildResources()}.
+ *
+ * @param path The path segment under this group at which to mount the
child.
+ * @param resource The {@code @Rest}-annotated resource instance.
+ * @return The newly-built child {@link RestContext}.
+ * @throws ServletException If construction or lifecycle initialization
of the child fails.
+ */
+ public RestContext addChild(String path, Object resource) throws
ServletException {
+ return getChildResources().addChild(path, resource);
+ }
+
+ /**
+ * Removes the child registered at the given composed path.
+ *
+ * <p>
+ * Convenience pass-through to {@link RestChildren#removeChild(String)}
on {@link #getChildResources()}.
+ *
+ * @param path The composed path key (as returned by {@link
RestContext#getPath()}).
+ * @return The removed and destroyed {@link RestContext}, or {@code
null} if no child was registered there.
+ */
+ public RestContext removeChild(String path) {
+ return getChildResources().removeChild(path);
+ }
+
+ /**
+ * Removes the first child whose resource class matches the given type.
+ *
+ * <p>
+ * Convenience pass-through to {@link RestChildren#removeChild(Class)}
on {@link #getChildResources()}.
+ *
+ * @param resourceClass The resource class to match.
+ * @return The removed and destroyed {@link RestContext}, or {@code
null} if no matching child was found.
+ */
+ public RestContext removeChild(Class<?> resourceClass) {
+ return getChildResources().removeChild(resourceClass);
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestServletGroup.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestServletGroup.java
index f1f83da556..f41c412ea3 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestServletGroup.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestServletGroup.java
@@ -21,6 +21,8 @@ import org.apache.juneau.rest.annotation.*;
import org.apache.juneau.rest.beans.*;
import org.apache.juneau.rest.config.*;
+import jakarta.servlet.*;
+
/**
* Specialized subclass of {@link BasicRestServlet} for showing "group" pages.
*
@@ -37,6 +39,14 @@ import org.apache.juneau.rest.config.*;
* <p>
* Children are attached to this resource using the {@link Rest#children()
@Rest(children)} annotation.
*
+ * <h5 class='section'>Dynamic children:</h5>
+ *
+ * <p>
+ * Additional child resources can be registered or unregistered at runtime via
{@link #addChild(Class)} /
+ * {@link #removeChild(String)} (and overloads). This is particularly useful
in test fixtures where a single
+ * group servlet is mounted once and individual tests plug in their own {@code
@Rest} resources without restarting
+ * the servlet container.
+ *
* <h5 class='section'>See Also:</h5><ul>
* <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/RestAnnotatedClassBasics">@Rest-Annotated
Class Basics</a>
* </ul>
@@ -51,4 +61,86 @@ public abstract class BasicRestServletGroup extends
BasicRestServlet implements
public ChildResourceDescriptions getChildren(RestRequest req) {
return ChildResourceDescriptions.of(req);
}
-}
\ No newline at end of file
+
+ /**
+ * Returns the {@link RestChildren} registry backing this group
resource.
+ *
+ * <p>
+ * Use this for full access to the dynamic add/remove API, including
the {@code replace} overloads and the
+ * unmodifiable {@link RestChildren#asMap() asMap()} snapshot.
+ *
+ * @return The {@link RestChildren} registry for this resource's {@link
RestContext}.
+ */
+ public RestChildren getChildResources() {
+ return getContext().getRestChildren();
+ }
+
+ /**
+ * Dynamically registers a child REST resource by class.
+ *
+ * <p>
+ * Convenience pass-through to {@link RestChildren#addChild(Class)} on
{@link #getChildResources()}.
+ *
+ * @param resourceClass The {@code @Rest}-annotated resource class.
+ * @return The newly-built child {@link RestContext}.
+ * @throws ServletException If construction or lifecycle initialization
of the child fails.
+ */
+ public RestContext addChild(Class<?> resourceClass) throws
ServletException {
+ return getChildResources().addChild(resourceClass);
+ }
+
+ /**
+ * Dynamically registers a pre-instantiated child REST resource.
+ *
+ * <p>
+ * Convenience pass-through to {@link RestChildren#addChild(Object)} on
{@link #getChildResources()}.
+ *
+ * @param resource The {@code @Rest}-annotated resource instance.
+ * @return The newly-built child {@link RestContext}.
+ * @throws ServletException If construction or lifecycle initialization
of the child fails.
+ */
+ public RestContext addChild(Object resource) throws ServletException {
+ return getChildResources().addChild(resource);
+ }
+
+ /**
+ * Dynamically registers a pre-instantiated child REST resource at an
explicit path.
+ *
+ * <p>
+ * Convenience pass-through to {@link RestChildren#addChild(String,
Object)} on {@link #getChildResources()}.
+ *
+ * @param path The path segment under this group at which to mount the
child.
+ * @param resource The {@code @Rest}-annotated resource instance.
+ * @return The newly-built child {@link RestContext}.
+ * @throws ServletException If construction or lifecycle initialization
of the child fails.
+ */
+ public RestContext addChild(String path, Object resource) throws
ServletException {
+ return getChildResources().addChild(path, resource);
+ }
+
+ /**
+ * Removes the child registered at the given composed path.
+ *
+ * <p>
+ * Convenience pass-through to {@link RestChildren#removeChild(String)}
on {@link #getChildResources()}.
+ *
+ * @param path The composed path key (as returned by {@link
RestContext#getPath()}).
+ * @return The removed and destroyed {@link RestContext}, or {@code
null} if no child was registered there.
+ */
+ public RestContext removeChild(String path) {
+ return getChildResources().removeChild(path);
+ }
+
+ /**
+ * Removes the first child whose resource class matches the given type.
+ *
+ * <p>
+ * Convenience pass-through to {@link RestChildren#removeChild(Class)}
on {@link #getChildResources()}.
+ *
+ * @param resourceClass The resource class to match.
+ * @return The removed and destroyed {@link RestContext}, or {@code
null} if no matching child was found.
+ */
+ public RestContext removeChild(Class<?> resourceClass) {
+ return getChildResources().removeChild(resourceClass);
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_Inject_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_Inject_Test.java
new file mode 100644
index 0000000000..14e8569555
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_Inject_Test.java
@@ -0,0 +1,235 @@
+/*
+ * 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.microservice;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.concurrent.atomic.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.commons.runtime.*;
+import org.apache.juneau.microservice.console.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for the inject-aware {@link Microservice} bootstrap.
+ *
+ * <p>
+ * Verifies that <c>@Configuration</c> classes registered via
+ * {@link Microservice.Builder#configurations(Class...)} are processed at
construction time,
+ * that <c>@Bean</c>-supplied values feed into field resolution when no
builder value is present,
+ * that explicit builder calls always win, and that the bean store is closed on
+ * {@link Microservice#stop()} so <c>@PreDestroy</c> hooks fire.
+ */
+class Microservice_Inject_Test extends TestBase {
+
+
//-----------------------------------------------------------------------------------------------------------------
+ // A. Bean store presence and self-registration.
+
//-----------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_beanStore_neverNull() throws Exception {
+ var ms = Microservice.create().build();
+ try {
+ assertNotNull(ms.getBeanStore());
+ } finally {
+ ms.stop();
+ }
+ }
+
+ @Test void a02_beanStore_containsMicroserviceItself() throws Exception {
+ var ms = Microservice.create().build();
+ try {
+ assertSame(ms,
ms.getBeanStore().getBean(Microservice.class).orElse(null));
+ } finally {
+ ms.stop();
+ }
+ }
+
+ @Test void a03_beanStore_containsResolvedArgs() throws Exception {
+ var args = new Args(new String[]{"--port", "8080"});
+ var ms = Microservice.create().args(args).build();
+ try {
+ assertSame(args,
ms.getBeanStore().getBean(Args.class).orElse(null));
+ } finally {
+ ms.stop();
+ }
+ }
+
+ @Test void a04_beanStore_isFreshWhenNotSupplied() throws Exception {
+ var ms1 = Microservice.create().build();
+ var ms2 = Microservice.create().build();
+ try {
+ assertNotSame(ms1.getBeanStore(), ms2.getBeanStore());
+ } finally {
+ ms1.stop();
+ ms2.stop();
+ }
+ }
+
+ @Test void a05_beanStore_externalStoreUsed() throws Exception {
+ WritableBeanStore external = new BasicBeanStore();
+ var ms = Microservice.create().beanStore(external).build();
+ try {
+ assertSame(external, ms.getBeanStore());
+ } finally {
+ ms.stop();
+ }
+ }
+
+
//-----------------------------------------------------------------------------------------------------------------
+ // B. @Configuration class registration via builder.
+
//-----------------------------------------------------------------------------------------------------------------
+
+ @Configuration
+ static class B_SimpleConfig {
+ @Bean MyService myService() { return new
MyService("from-config"); }
+ }
+
+ static class MyService {
+ final String tag;
+ MyService(String tag) { this.tag = tag; }
+ }
+
+ @Test void b01_configuration_beanIsRegistered() throws Exception {
+ var ms =
Microservice.create().configurations(B_SimpleConfig.class).build();
+ try {
+ var svc =
ms.getBeanStore().getBean(MyService.class).orElse(null);
+ assertNotNull(svc);
+ assertEquals("from-config", svc.tag);
+ } finally {
+ ms.stop();
+ }
+ }
+
+ @Test void b02_configurations_noneRegistered_emptyStore() throws
Exception {
+ var ms = Microservice.create().build();
+ try {
+
assertFalse(ms.getBeanStore().getBean(MyService.class).isPresent());
+ } finally {
+ ms.stop();
+ }
+ }
+
+
//-----------------------------------------------------------------------------------------------------------------
+ // C. Resolution priority: builder > @Bean > default.
+
//-----------------------------------------------------------------------------------------------------------------
+
+ @Configuration
+ static class C_ArgsConfig {
+ @Bean Args args() { return new Args(new String[]{"--from",
"config"}); }
+ }
+
+ @Test void c01_builderArgs_beatsConfigurationArgs() throws Exception {
+ var builderArgs = new Args(new String[]{"--from", "builder"});
+ var ms = Microservice.create()
+ .args(builderArgs)
+ .configurations(C_ArgsConfig.class)
+ .build();
+ try {
+ assertSame(builderArgs, ms.getArgs());
+ assertSame(builderArgs,
ms.getBeanStore().getBean(Args.class).orElse(null));
+ } finally {
+ ms.stop();
+ }
+ }
+
+ @Test void c02_configurationArgs_usedWhenNoBuilderArgs() throws
Exception {
+ var ms = Microservice.create()
+ .configurations(C_ArgsConfig.class)
+ .build();
+ try {
+ assertEquals("config",
ms.getArgs().get("from").orElse(null));
+ } finally {
+ ms.stop();
+ }
+ }
+
+
//-----------------------------------------------------------------------------------------------------------------
+ // D. Console commands contributed via @Bean.
+
//-----------------------------------------------------------------------------------------------------------------
+
+ @Configuration
+ static class D_CommandConfig {
+ @Bean ConsoleCommand customCommand() {
+ return new ConsoleCommand() {
+ @Override public String getName() { return
"custom"; }
+ @Override public String getInfo() { return
"Custom command for tests."; }
+ @Override public boolean
execute(java.util.Scanner in, java.io.PrintWriter out, Args a) { return false; }
+ };
+ }
+ }
+
+ @Test void d01_configurationProvidedConsoleCommand_isPickedUp() throws
Exception {
+ // Console commands are only wired when consoleEnabled=true.
+ var ms = Microservice.create()
+ .consoleEnabled(true)
+ .configurations(D_CommandConfig.class)
+ .build();
+ try {
+
assertTrue(ms.getConsoleCommands().containsKey("custom"));
+ } finally {
+ ms.stop();
+ }
+ }
+
+
//-----------------------------------------------------------------------------------------------------------------
+ // E. Lifecycle hooks: @PostConstruct + @PreDestroy.
+
//-----------------------------------------------------------------------------------------------------------------
+
+ static final AtomicInteger POST_CONSTRUCT_COUNT = new AtomicInteger();
+ static final AtomicInteger PRE_DESTROY_COUNT = new AtomicInteger();
+
+ public static class LifecycleBean {
+ @PostConstruct public void onCreate() {
POST_CONSTRUCT_COUNT.incrementAndGet(); }
+ @PreDestroy public void onDestroy() {
PRE_DESTROY_COUNT.incrementAndGet(); }
+ }
+
+ @Configuration
+ static class E_LifecycleConfig {
+ @Bean LifecycleBean lifecycleBean() { return new
LifecycleBean(); }
+ }
+
+ @Test void e01_lifecycle_postConstructAndPreDestroy_fire() throws
Exception {
+ POST_CONSTRUCT_COUNT.set(0);
+ PRE_DESTROY_COUNT.set(0);
+ var ms =
Microservice.create().configurations(E_LifecycleConfig.class).build();
+ // @PostConstruct does NOT fire for beans returned from @Bean
methods unless they pass through
+ // BeanInstantiator (which @Bean returns don't). This test
pins the documented behavior:
+ // @PreDestroy fires when the bean has been resolved + the
store is closed.
+ var bean =
ms.getBeanStore().getBean(LifecycleBean.class).orElse(null);
+ assertNotNull(bean);
+ ms.stop();
+ assertEquals(1, PRE_DESTROY_COUNT.get(), "@PreDestroy should
fire once after stop()");
+ }
+
+
//-----------------------------------------------------------------------------------------------------------------
+ // F. Back-compat: existing builder-only path still works.
+
//-----------------------------------------------------------------------------------------------------------------
+
+ @Test void f01_legacyBuilderPath_stillWorks() throws Exception {
+ var ms = Microservice.create()
+ .args("--from", "builder")
+ .build();
+ try {
+ assertEquals("builder",
ms.getArgs().get("from").orElse(null));
+ assertNotNull(ms.getBeanStore()); // store is now
always present, but legacy path stays functional
+ } finally {
+ ms.stop();
+ }
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/microservice/jetty/JettyMicroservice_Inject_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/microservice/jetty/JettyMicroservice_Inject_Test.java
new file mode 100644
index 0000000000..5f3fcfe947
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/microservice/jetty/JettyMicroservice_Inject_Test.java
@@ -0,0 +1,252 @@
+/*
+ * 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.microservice.jetty;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.servlet.*;
+import org.eclipse.jetty.ee11.servlet.*;
+import org.eclipse.jetty.server.*;
+import org.junit.jupiter.api.*;
+
+import jakarta.servlet.*;
+import jakarta.servlet.http.*;
+
+/**
+ * Tests for the inject-aware {@link JettyMicroservice} bootstrap.
+ *
+ * <p>
+ * Verifies that <c>@Configuration</c>-supplied <c>JettyServerFactory</c> /
+ * <c>JettyMicroserviceListener</c> / <c>Server</c> / <c>Servlet</c> beans are
picked up,
+ * that explicit builder calls take precedence, that auto-discovered
<c>@Rest</c> servlets
+ * are mounted at <c>@Rest(path=...)</c>, and that duplicate mount paths fail
fast.
+ */
+class JettyMicroservice_Inject_Test extends TestBase {
+
+
//-----------------------------------------------------------------------------------------------------------------
+ // A. Bean store presence + self-registration of Jetty-specific beans.
+
//-----------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_beanStore_containsJettyMicroservice() throws Exception {
+ var ms = JettyMicroservice.create().build();
+ try {
+ assertSame(ms,
ms.getBeanStore().getBean(JettyMicroservice.class).orElse(null));
+ } finally {
+ ms.stop();
+ }
+ }
+
+ @Test void a02_beanStore_containsJettyListener() throws Exception {
+ var ms = JettyMicroservice.create().build();
+ try {
+
assertNotNull(ms.getBeanStore().getBean(JettyMicroserviceListener.class).orElse(null));
+ } finally {
+ ms.stop();
+ }
+ }
+
+ @Test void a03_beanStore_containsJettyServerFactory() throws Exception {
+ var ms = JettyMicroservice.create().build();
+ try {
+
assertNotNull(ms.getBeanStore().getBean(JettyServerFactory.class).orElse(null));
+ } finally {
+ ms.stop();
+ }
+ }
+
+
//-----------------------------------------------------------------------------------------------------------------
+ // B. Resolution priority: explicit builder > @Bean > default.
+
//-----------------------------------------------------------------------------------------------------------------
+
+ static class TestFactory extends BasicJettyServerFactory {
+ // Marker for identity checks; behavior inherited from
BasicJettyServerFactory.
+ }
+
+ @Configuration
+ static class B_FactoryConfig {
+ @Bean JettyServerFactory factory() { return new TestFactory(); }
+ }
+
+ @Test void b01_explicitFactory_beatsConfigurationFactory() throws
Exception {
+ var explicit = new BasicJettyServerFactory();
+ var ms = JettyMicroservice.create()
+ .jettyServerFactory(explicit)
+ .configurations(B_FactoryConfig.class)
+ .build();
+ try {
+ assertSame(explicit,
ms.getBeanStore().getBean(JettyServerFactory.class).orElse(null));
+ } finally {
+ ms.stop();
+ }
+ }
+
+ @Test void b02_configurationFactory_usedWhenNoBuilderFactory() throws
Exception {
+ var ms = JettyMicroservice.create()
+ .configurations(B_FactoryConfig.class)
+ .build();
+ try {
+ assertInstanceOf(TestFactory.class,
ms.getBeanStore().getBean(JettyServerFactory.class).orElse(null));
+ } finally {
+ ms.stop();
+ }
+ }
+
+ @Configuration
+ static class B_ListenerConfig {
+ @Bean JettyMicroserviceListener listener() { return new
BasicJettyMicroserviceListener() {}; }
+ }
+
+ @Test void b03_configurationListener_usedWhenNoBuilderListener() throws
Exception {
+ var ms = JettyMicroservice.create()
+ .configurations(B_ListenerConfig.class)
+ .build();
+ try {
+ // The contributed listener bean should be the resolved
one.
+ var configured =
ms.getBeanStore().getBeansOfType(JettyMicroserviceListener.class).values().iterator().next();
+ assertNotNull(configured);
+ assertNotSame(BasicJettyMicroserviceListener.class,
configured.getClass()); // it's the anonymous subclass
+ } finally {
+ ms.stop();
+ }
+ }
+
+
//-----------------------------------------------------------------------------------------------------------------
+ // C. End-to-end: @Bean Server + @Bean Servlet auto-mounted at
@Rest(path=...).
+
//-----------------------------------------------------------------------------------------------------------------
+
+ @Rest(path = "/api")
+ public static class ApiServlet extends RestServlet {
+ private static final long serialVersionUID = 1L;
+ }
+
+ public static class PlainServlet extends HttpServlet {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @Configuration
+ static class C_AutoMountConfig {
+ @Bean Server jettyServer() {
+ var server = new Server();
+ var ctx = new ServletContextHandler();
+ ctx.setContextPath("/");
+ server.setAttribute("ServletContextHandler", ctx);
+ server.setHandler(ctx);
+ return server;
+ }
+ // Declare return type as Servlet so the bean is registered
under Servlet.class
+ // (BeanStore.getBeansOfType is exact-type, so subtype lookup
needs the supertype declaration).
+ @Bean Servlet apiServlet() { return new ApiServlet(); }
+ }
+
+ @Test void c01_restServletAutoMounted_atRestPath() throws Exception {
+ var ms = JettyMicroservice.create()
+ .configurations(C_AutoMountConfig.class)
+ .build();
+ try {
+ ms.createServer();
+ var ctx = ms.getServletContextHandler();
+ var mounted = false;
+ for (var h :
ctx.getServletHandler().getServletMappings()) {
+ for (var p : h.getPathSpecs()) {
+ if ("/api/*".equals(p)) {
+ mounted = true;
+ break;
+ }
+ }
+ }
+ assertTrue(mounted, "@Rest servlet should be
auto-mounted at /api/*");
+ } finally {
+ ms.stop();
+ }
+ }
+
+ @Configuration
+ static class C_NoRestServletConfig {
+ @Bean Server jettyServer() {
+ var server = new Server();
+ var ctx = new ServletContextHandler();
+ ctx.setContextPath("/");
+ server.setAttribute("ServletContextHandler", ctx);
+ server.setHandler(ctx);
+ return server;
+ }
+ @Bean Servlet plainServlet() { return new PlainServlet(); }
+ }
+
+ @Test void c02_servletWithoutRestAnnotation_isNotAutoMounted() throws
Exception {
+ var ms = JettyMicroservice.create()
+ .configurations(C_NoRestServletConfig.class)
+ .build();
+ try {
+ ms.createServer();
+ var ctx = ms.getServletContextHandler();
+ // PlainServlet has no @Rest, so it should NOT be
auto-mounted.
+ assertEquals(0,
ctx.getServletHandler().getServletMappings().length,
+ "plain Servlet without @Rest should not be
auto-mounted");
+ } finally {
+ ms.stop();
+ }
+ }
+
+
//-----------------------------------------------------------------------------------------------------------------
+ // D. Path collision is a hard startup failure.
+
//-----------------------------------------------------------------------------------------------------------------
+
+ @Configuration
+ static class D_CollisionConfig {
+ @Bean Server jettyServer() {
+ var server = new Server();
+ var ctx = new ServletContextHandler();
+ ctx.setContextPath("/");
+ server.setAttribute("ServletContextHandler", ctx);
+ server.setHandler(ctx);
+ return server;
+ }
+ @Bean Servlet apiServlet1() { return new ApiServlet(); }
+ @Bean(name = "apiServletDup") Servlet apiServlet2() { return
new ApiServlet(); }
+ }
+
+ @Test void d01_pathCollision_failsHard() throws Exception {
+ var ms = JettyMicroservice.create()
+ .configurations(D_CollisionConfig.class)
+ .build();
+ try {
+ var ex = assertThrows(Exception.class,
ms::createServer);
+ var root = ThrowableUtils.getCause(ex);
+ assertTrue(root.getMessage().contains("Servlet mount
path collision"),
+ "expected path-collision error, got: " +
root.getMessage());
+ } finally {
+ ms.stop();
+ }
+ }
+
+
//-----------------------------------------------------------------------------------------------------------------
+ // E. Helper for unwrapping wrapped exceptions.
+
//-----------------------------------------------------------------------------------------------------------------
+
+ static final class ThrowableUtils {
+ static Throwable getCause(Throwable t) {
+ var cur = t;
+ while (cur.getCause() != null && cur.getCause() != cur)
+ cur = cur.getCause();
+ return cur;
+ }
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/BasicRestServletGroup_DynamicChildren_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/BasicRestServletGroup_DynamicChildren_Test.java
new file mode 100644
index 0000000000..c0d74cf17c
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/BasicRestServletGroup_DynamicChildren_Test.java
@@ -0,0 +1,515 @@
+/*
+ * 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 java.util.*;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for runtime add/remove of child REST resources via {@link
BasicRestServletGroup#addChild} and friends, which
+ * delegate to {@link RestChildren}.
+ *
+ * <p>Each scenario uses a distinct parent class so the {@link
MockRestClient}'s per-class
+ * {@link RestContext} cache does not bleed mutations across tests.
+ */
+class BasicRestServletGroup_DynamicChildren_Test extends TestBase {
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Reusable child resources.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest(path = "/alpha")
+ public static class AlphaChild {
+ @RestGet(path = "/ping")
+ public String ping() {
+ return "alpha-pong";
+ }
+ }
+
+ @Rest(path = "/beta")
+ public static class BetaChild {
+ @RestGet(path = "/ping")
+ public String ping() {
+ return "beta-pong";
+ }
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // a01: addChild(Class<?>) — instantiates via bean store, mounts at
@Rest(path).
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest(path = "/root")
+ public static class A_Parent extends BasicRestServletGroup {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @Test
+ void a01_addChild_byClass() throws Exception {
+ var parent = new A_Parent();
+ var client = MockRestClient.createLax(parent).build();
+
+ client.get("/alpha/ping").run().assertStatus(404);
+
+ parent.addChild(AlphaChild.class);
+
+
client.get("/alpha/ping").run().assertStatus(200).assertContent("alpha-pong");
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // a02: addChild(Object) — pre-instantiated resource.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest(path = "/root")
+ public static class A02_Parent extends BasicRestServletGroup {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @Test
+ void a02_addChild_byInstance() throws Exception {
+ var parent = new A02_Parent();
+ var client = MockRestClient.build(parent);
+ var instance = new AlphaChild();
+
+ var ctx = parent.addChild(instance);
+
+ assertSame(instance, ctx.getResource());
+ client.get("/alpha/ping").run().assertContent("alpha-pong");
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // a03: addChild(String, Object) — explicit path override (mount same
class at different path).
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest(path = "/root")
+ public static class A03_Parent extends BasicRestServletGroup {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @Test
+ void a03_addChild_withPathOverride() throws Exception {
+ var parent = new A03_Parent();
+ var client = MockRestClient.build(parent);
+
+ parent.addChild(new AlphaChild()); // /alpha
+ parent.addChild("/gamma", new AlphaChild()); // /gamma
(override of @Rest(path="/alpha"))
+
+ client.get("/alpha/ping").run().assertContent("alpha-pong");
+ client.get("/gamma/ping").run().assertContent("alpha-pong");
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // b01: removeChild(String) — 404 after removal; insertion order
preserved for remaining children.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest(path = "/root")
+ public static class B_Parent extends BasicRestServletGroup {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @Test
+ void b01_removeChild_byPath() throws Exception {
+ var parent = new B_Parent();
+ var client = MockRestClient.createLax(parent).build();
+
+ parent.addChild(AlphaChild.class);
+ parent.addChild(BetaChild.class);
+
+ client.get("/alpha/ping").run().assertStatus(200);
+ client.get("/beta/ping").run().assertStatus(200);
+
+ var removed = parent.removeChild("alpha");
+ assertNotNull(removed);
+ assertEquals("alpha", removed.getPath());
+
+ client.get("/alpha/ping").run().assertStatus(404);
+ client.get("/beta/ping").run().assertStatus(200);
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // b02: removeChild(Class<?>) — matches by resource class.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest(path = "/root")
+ public static class B02_Parent extends BasicRestServletGroup {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @Test
+ void b02_removeChild_byClass() throws Exception {
+ var parent = new B02_Parent();
+ var client = MockRestClient.createLax(parent).build();
+
+ parent.addChild(AlphaChild.class);
+ parent.addChild(BetaChild.class);
+
+ var removed = parent.removeChild(BetaChild.class);
+ assertNotNull(removed);
+ assertEquals("beta", removed.getPath());
+
+ client.get("/alpha/ping").run().assertStatus(200);
+ client.get("/beta/ping").run().assertStatus(404);
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // b03: removeChild on non-existent path/class returns null.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest(path = "/root")
+ public static class B03_Parent extends BasicRestServletGroup {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @Test
+ void b03_removeChild_missing_returnsNull() {
+ var parent = new B03_Parent();
+ MockRestClient.build(parent);
+
+ assertNull(parent.removeChild("doesnotexist"));
+ assertNull(parent.removeChild(AlphaChild.class));
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // c01: Duplicate-path add throws IllegalStateException; original child
stays intact.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest(path = "/root")
+ public static class C_Parent extends BasicRestServletGroup {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @Test
+ void c01_addChild_duplicatePath_throws() throws Exception {
+ var parent = new C_Parent();
+ var client = MockRestClient.build(parent);
+
+ parent.addChild(AlphaChild.class);
+ assertThrows(IllegalStateException.class, () ->
parent.addChild(AlphaChild.class));
+
+ // Original child still routes correctly.
+ client.get("/alpha/ping").run().assertContent("alpha-pong");
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // c02: addChild(Object, replace=true) — evicts existing child at the
same path.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest(path = "/root")
+ public static class C02_Parent extends BasicRestServletGroup {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @Test
+ void c02_addChild_replace_evictsExisting() throws Exception {
+ var parent = new C02_Parent();
+ var client = MockRestClient.build(parent);
+ var rc = parent.getChildResources();
+
+ var original = new AlphaChild();
+ rc.addChild(original);
+
+ var replacement = new AlphaChild();
+ var newCtx = rc.addChild(replacement, true);
+
+ assertSame(replacement, newCtx.getResource());
+ assertNotSame(original, newCtx.getResource());
+ client.get("/alpha/ping").run().assertContent("alpha-pong");
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // d01: Insertion order preserved in asMap() snapshot.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest(path = "/root")
+ public static class D_Parent extends BasicRestServletGroup {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @Test
+ void d01_insertionOrderPreserved() throws Exception {
+ var parent = new D_Parent();
+ MockRestClient.build(parent);
+
+ parent.addChild(BetaChild.class);
+ parent.addChild(AlphaChild.class);
+
+ var keys = new
ArrayList<>(parent.getChildResources().asMap().keySet());
+ assertEquals(List.of("beta", "alpha"), keys);
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // e01: @RestInit / @RestPostInit fire on dynamically added child.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest(path = "/root")
+ public static class E_Parent extends BasicRestServletGroup {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @Rest(path = "/lifecycle")
+ public static class LifecycleChild {
+ public final List<String> events =
Collections.synchronizedList(new ArrayList<>());
+
+ @RestInit
+ public void init() {
+ events.add("init");
+ }
+
+ @RestPostInit
+ public void postInit() {
+ events.add("postInit");
+ }
+
+ @RestPostInit(childFirst = true)
+ public void postInitChildFirst() {
+ events.add("postInitChildFirst");
+ }
+
+ @RestGet(path = "/events")
+ public List<String> events() {
+ return events;
+ }
+ }
+
+ @Test
+ void e01_lifecycleHooks_fireOnDynamicAdd() throws Exception {
+ var parent = new E_Parent();
+ MockRestClient.build(parent);
+
+ var child = new LifecycleChild();
+ parent.addChild(child);
+
+ // @RestInit fires during construction; @RestPostInit hooks
fire from addChild's postInit / postInitChildFirst calls.
+ assertTrue(child.events.contains("init"));
+ assertTrue(child.events.contains("postInit"));
+ assertTrue(child.events.contains("postInitChildFirst"));
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // e02: @RestDestroy fires on removeChild.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest(path = "/root")
+ public static class E02_Parent extends BasicRestServletGroup {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @Rest(path = "/destroyable")
+ public static class DestroyableChild {
+ public final AtomicBoolean destroyed = new AtomicBoolean(false);
+
+ @RestDestroy
+ public void onDestroy() {
+ destroyed.set(true);
+ }
+
+ @RestGet(path = "/ping")
+ public String ping() {
+ return "ok";
+ }
+ }
+
+ @Test
+ void e02_destroyHook_firesOnRemove() throws Exception {
+ var parent = new E02_Parent();
+ MockRestClient.build(parent);
+
+ var child = new DestroyableChild();
+ parent.addChild(child);
+ assertFalse(child.destroyed.get());
+
+ parent.removeChild("destroyable");
+ assertTrue(child.destroyed.get(), "@RestDestroy hook should
have fired on removeChild");
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // f01: Concurrent add/remove + routing — sanity smoke (no CME, final
state correct).
+ //
+ // This is a lightweight smoke test, not a true stress test — the goal
is to exercise the volatile snapshot read
+ // path under contention and assert that visible routes always resolve
correctly.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest(path = "/root")
+ public static class F_Parent extends BasicRestServletGroup {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @Test
+ void f01_concurrentAddRemoveAndRoute_smoke() throws Exception {
+ var parent = new F_Parent();
+ var client = MockRestClient.build(parent);
+
+ // Seed with a permanent child that should always be routable.
+ parent.addChild(AlphaChild.class);
+
+ var stop = new AtomicBoolean(false);
+ var errors = Collections.synchronizedList(new
ArrayList<Throwable>());
+
+ // Reader threads — hammer findMatch through MockRestClient
against the permanent child.
+ var readers = new ArrayList<Thread>();
+ for (var i = 0; i < 4; i++) {
+ readers.add(new Thread(() -> {
+ try {
+ while (! stop.get())
+
client.get("/alpha/ping").run().assertStatus(200);
+ } catch (Throwable t) {
+ errors.add(t);
+ }
+ }));
+ }
+
+ // Writer threads — repeatedly add and remove a transient child
class.
+ var writers = new ArrayList<Thread>();
+ for (var i = 0; i < 2; i++) {
+ writers.add(new Thread(() -> {
+ try {
+ for (var j = 0; j < 50 && ! stop.get();
j++) {
+ try {
+
parent.addChild(BetaChild.class);
+ } catch (IllegalStateException
dup) {
+ // Two writers racing
on the same class is expected — ignore.
+ }
+
parent.removeChild(BetaChild.class);
+ }
+ } catch (Throwable t) {
+ errors.add(t);
+ }
+ }));
+ }
+
+ readers.forEach(Thread::start);
+ writers.forEach(Thread::start);
+
+ for (var w : writers)
+ w.join(TimeUnit.SECONDS.toMillis(15));
+ stop.set(true);
+ for (var r : readers)
+ r.join(TimeUnit.SECONDS.toMillis(15));
+
+ if (! errors.isEmpty())
+ throw new AssertionError("Concurrent operations
produced errors: " + errors.get(0), errors.get(0));
+
+ // Final state: AlphaChild still mounted; BetaChild may or may
not be present (last write wins).
+ client.get("/alpha/ping").run().assertStatus(200);
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // g01: addChild(Class<?>) where the class is pre-registered as a bean
— buildChildContext resolves via bean store.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest(path = "/root")
+ public static class G_Parent extends BasicRestServletGroup {
+ private static final long serialVersionUID = 1L;
+
+ private final AlphaChild prebuiltAlpha = new AlphaChild();
+
+ @Bean
+ public AlphaChild prebuiltAlpha() {
+ return prebuiltAlpha;
+ }
+ }
+
+ @Test
+ void g01_addChild_byClass_resolvesFromBeanStore() throws Exception {
+ var parent = new G_Parent();
+ var client = MockRestClient.build(parent);
+
+ var ctx = parent.addChild(AlphaChild.class);
+
+ assertSame(parent.prebuiltAlpha, ctx.getResource(),
"Class-based addChild should resolve from bean store when a matching bean is
registered.");
+ client.get("/alpha/ping").run().assertContent("alpha-pong");
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // h01: Servlet-typed child — setContext reflective invocation runs and
Servlet.destroy() fires on remove.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest(path = "/servletchild")
+ public static class ServletChild extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ public final AtomicBoolean destroyed = new AtomicBoolean(false);
+
+ @RestGet(path = "/ping")
+ public String ping() {
+ return "servlet-pong";
+ }
+
+ @Override
+ public void destroy() {
+ destroyed.set(true);
+ super.destroy();
+ }
+ }
+
+ @Rest(path = "/root")
+ public static class H_Parent extends BasicRestServletGroup {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @Test
+ void h01_servletChild_setContextAndDestroyFire() throws Exception {
+ var parent = new H_Parent();
+ var client = MockRestClient.createLax(parent).build();
+
+ var child = new ServletChild();
+ parent.addChild(child);
+
+ // setContext on the Servlet child was invoked during addChild
— verify by issuing a successful request.
+ // (BasicRestServlet defaults to text/html which wraps
responses, so assert on a substring rather than equality.)
+
client.get("/servletchild/ping").run().assertStatus(200).assertContent().asString().isContains("servlet-pong");
+
+ parent.removeChild("servletchild");
+ assertTrue(child.destroyed.get(), "Servlet.destroy() should
fire when a Servlet-typed child is removed.");
+ client.get("/servletchild/ping").run().assertStatus(404);
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // i01: addChild(String, Object, boolean replace=true) — explicit path
with replace semantics.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest(path = "/root")
+ public static class I_Parent extends BasicRestServletGroup {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @Test
+ void i01_addChild_explicitPath_withReplace() throws Exception {
+ var parent = new I_Parent();
+ var client = MockRestClient.build(parent);
+ var rc = parent.getChildResources();
+
+ var original = new AlphaChild();
+ rc.addChild("/gamma", original);
+
+ var replacement = new BetaChild();
+ var newCtx = rc.addChild("/gamma", replacement, true);
+
+ assertSame(replacement, newCtx.getResource());
+ assertEquals("gamma", newCtx.getPath());
+ // /gamma now routes through BetaChild even though the
@Rest(path) on BetaChild is "/beta".
+ client.get("/gamma/ping").run().assertContent("beta-pong");
+ }
+}
diff --git a/todo/TODO-11-restclient2-transport-abstraction.md
b/todo/FINISHED-11a-restclient-ng-design-plan.md
similarity index 99%
rename from todo/TODO-11-restclient2-transport-abstraction.md
rename to todo/FINISHED-11a-restclient-ng-design-plan.md
index 388165ae90..4b607ad897 100644
--- a/todo/TODO-11-restclient2-transport-abstraction.md
+++ b/todo/FINISHED-11a-restclient-ng-design-plan.md
@@ -1,4 +1,8 @@
-# RestClient Next-Generation Transport Abstraction Plan
+# FINISHED-11a: RestClient Next-Generation Transport Abstraction — Design Plan
+
+> **Archived from `TODO-11-restclient2-transport-abstraction.md`.** This file
preserves the original design plan that drove the next-generation REST client
(`org.apache.juneau.ng.*`) implementation. The implementation itself —
including all packages, modules, transport adapters, mock transport, and unit
tests — has landed. The remaining cleanup (test-coverage closeout in
`org.apache.juneau.ng.http`) is tracked in
`todo/TODO-11-restclient-ng-coverage-closeout.md`. The text below is preser
[...]
+
+---
## Problem Statement
diff --git a/todo/FINISHED-31-inject-aware-microservice.md
b/todo/FINISHED-31-inject-aware-microservice.md
new file mode 100644
index 0000000000..c47f4a3a99
--- /dev/null
+++ b/todo/FINISHED-31-inject-aware-microservice.md
@@ -0,0 +1,159 @@
+# FINISHED-31: Inject-aware Microservice
+
+## Summary
+
+`juneau-microservice-core` and `juneau-microservice-jetty` are now first-class
consumers of the
+inject framework (`BeanStore`, `@Configuration`, `@Bean`, `@PostConstruct`,
`@PreDestroy`,
+`@Primary`, `@Order`, `@Conditional`). An application or test can bootstrap a
microservice from
+one or more `@Configuration` classes whose `@Bean` methods produce the
resources, servlets,
+listeners, and infrastructure the microservice needs.
+
+Existing builder/config-file workflow remains byte-for-byte unchanged.
+
+## What shipped
+
+### Phase 1 — `juneau-microservice-core`
+
+`Microservice` gains an internal `WritableBeanStore` (accessible via
+`Microservice.getBeanStore()`). Bootstrap order:
+
+1. `WritableBeanStore` constructed (or taken from builder via new
`.beanStore(...)` method).
+2. `@Configuration` classes registered via new `.configurations(Class<?>...)` /
+ `.configurations(List<Class<?>>)` builder methods are processed first;
`@Bean` methods are
+ invoked with parameter injection and their results land in the store.
+3. Each microservice field (`Args`, `ManifestFile`, `Config`, `VarResolver`,
`MicroserviceListener`)
+ is resolved with priority **explicit builder > `@Bean` > built-in
default**, then registered
+ into the store (overwriting any `@Bean` contribution under the same `(type,
name)`).
+4. The microservice instance itself is self-registered as a bean.
+5. Console commands contributed via `@Bean ConsoleCommand` are merged into the
console command map
+ alongside builder-supplied and `Console/commands` config-file entries.
+
+Lifecycle:
+
+- `Microservice.stop()` closes the bean store, which walks every **resolved**
bean in LIFO order
+ and invokes `@PreDestroy` methods. Errors are logged at `WARNING` and do not
abort the rest of
+ the shutdown sequence.
+- `@PostConstruct` callbacks fire automatically on beans instantiated through
the inject framework
+ (e.g. when a `@Bean` factory method depends on another `@Bean`-supplied
dependency, or via
+ `BeanStore.instantiate(X)`).
+
+New API surface on `Microservice.Builder`:
+
+```java
+public Builder configurations(Class<?>... configs)
+public Builder configurations(List<Class<?>> configs)
+public Builder beanStore(WritableBeanStore externalStore)
+```
+
+New API surface on `Microservice`:
+
+```java
+public WritableBeanStore getBeanStore()
+```
+
+### Phase 2 — `juneau-microservice-jetty`
+
+`JettyMicroservice.Builder` overrides the new builder methods for
fluent-typing. The constructor
+self-registers `JettyMicroservice`, `JettyMicroserviceListener`, and
`JettyServerFactory` into the
+store, and looks up `@Bean`-supplied values when the builder doesn't provide
them.
+
+`createServer()`:
+
+- If a `@Bean Server` is present in the store (with the
`"ServletContextHandler"` attribute set),
+ uses it directly and skips the `jetty.xml` factory step entirely. Otherwise,
uses the resolved
+ `JettyServerFactory` to build the server from `jetty.xml` as before.
+- Publishes the created `Server` back to the bean store.
+- Iterates `store.getBeansOfType(Servlet.class)` and auto-mounts every servlet
whose runtime class
+ carries `@Rest`, at the path declared by `@Rest(path = "...")` (defaulting
to `/` when no path is
+ set).
+- Tracks every mounted pathspec with its declaring source
(`Jetty/servlets[FQN]`,
+ `Builder.servlet(FQN)`, `@Bean FQN[name]`) and throws a `RuntimeException`
with both
+ contributors on path collision. Previously silent collisions across the five
servlet sources are
+ now hard startup failures.
+
+### Phase 3 — Docs + release notes
+
+- New topic page `pages/topics/14.09.InjectAwareMicroservice.md` (slug
`MicroserviceCoreInject`)
+ with a worked example, the resolution-priority table, the auto-discovery
semantics, lifecycle
+ notes, external-store integration, and back-compat guarantees.
+- Sidebar entry added at `14.9. Inject-Aware Microservice`.
+- Two new sections in `pages/release-notes/9.5.0.md` (under `###
juneau-microservice-core` and
+ `### juneau-microservice-jetty`) summarizing the new builder methods, the
`getBeanStore()`
+ accessor, lifecycle semantics, `@Rest` servlet auto-mount behavior, and the
path-collision
+ hard-failure invariant.
+
+## Tests
+
+New test classes in `juneau-utest`:
+
+- `Microservice_Inject_Test` (12 tests): bean-store presence and
self-registration, external bean
+ store, `@Configuration` bean registration, resolution priority (builder >
`@Bean`),
+ configuration-contributed console commands, `@PreDestroy` lifecycle, legacy
builder back-compat.
+- `JettyMicroservice_Inject_Test` (9 tests): Jetty-specific self-registration,
explicit
+ `JettyServerFactory` / `JettyMicroserviceListener` precedence over `@Bean`
values, end-to-end
+ `@Bean Server` + `@Bean Servlet` auto-mount at `@Rest(path = ...)`, servlets
without `@Rest` are
+ not auto-mounted, path collisions fail hard.
+
+All 183 existing microservice-related tests (`Microservice_Builder_Test`,
+`BasicMicroserviceListener_Test`, `JettyMicroservice_Builder_Test`,
+`BasicJettyMicroserviceListener_Test`, `JettyLogger_Test`, `LogConfig_Test`,
+`ConsoleCommand_Test`) continue to pass unchanged. Full `./scripts/test.py
--full` is green.
+
+## Notable design decisions
+
+1. **Builder values stay authoritative.** `Microservice` does not move its
fields into the store
+ wholesale. Instead, the constructor resolves each field with `builder >
store > default`
+ precedence and then writes the final value into the store. This preserves
the existing
+ "explicit builder call always wins" invariant while still letting `@Bean`
methods supply
+ defaults when the builder is silent.
+
+2. **`@Bean` return types must match the lookup type.**
`BeanStore.getBeansOfType(Class)` is
+ exact-type (not assignable-to). The user-facing contract documented in the
new topic page:
+ declare `@Bean` methods that produce a servlet with return type `Servlet`,
not the concrete
+ subclass. Spring-compatible "assignable-to" semantics would require an
additive change to the
+ `BeanStore` API and is intentionally out of scope here; can be revisited if
a follow-on need
+ emerges.
+
+3. **`@Bean.name()` is not repurposed as a mount path.** `@Bean(name = ...)`
already has a
+ well-defined meaning (disambiguate multiple beans of the same Java type).
The mount path comes
+ solely from `@Rest(path = ...)` on the servlet class, with `/` as the
fallback. If multi-mount
+ ever becomes a real requirement, a dedicated `@Mount("/path")` annotation
is the right answer,
+ not overloading `@Bean.name()`.
+
+4. **`@PreDestroy` only fires for resolved beans.** This is the existing
`BasicBeanStore.close()`
+ contract — beans registered but never fetched via `getBean` /
`getBeansOfType` are not tracked
+ and won't receive a `@PreDestroy` callback. For a microservice this almost
never matters in
+ practice because servlets, listeners, and console commands are all pulled
from the store
+ during start-up.
+
+5. **Path-collision is a hard failure, not a warning.** Across all five
servlet sources
+ (`Jetty/servlets`, `Jetty/servletMap`, `Jetty/servletAttributes` via
config, `.servlet(...)`
+ builder calls, `@Bean` discovery), `createServer()` tracks the first
contributor for each
+ normalized pathspec and throws `RuntimeException` with both names if a
second contributor
+ claims the same path. Previously these silently chained additional
`ServletHolder` instances
+ onto the same pathspec.
+
+## Files touched
+
+**Core code:**
+
+-
`juneau-microservice/juneau-microservice-core/src/main/java/org/apache/juneau/microservice/Microservice.java`
+-
`juneau-microservice/juneau-microservice-jetty/src/main/java/org/apache/juneau/microservice/jetty/JettyMicroservice.java`
+
+**Tests:**
+
+-
`juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_Inject_Test.java`
+-
`juneau-utest/src/test/java/org/apache/juneau/microservice/jetty/JettyMicroservice_Inject_Test.java`
+
+**Docs:**
+
+- `juneau-docs/pages/topics/14.09.InjectAwareMicroservice.md`
+- `juneau-docs/pages/release-notes/9.5.0.md`
+- `juneau-docs/sidebars.ts`
+
+## Downstream unblocked
+
+[TODO-11] — RestClient NG closeout — cross-transport remote-interface test
suite. The
+`MicroserviceTestFixture` plan in
`todo/TODO-11-restclient-ng-coverage-closeout.md` can now build
+on top of `Microservice.create().configurations(...)` with a `@Configuration`
test class supplying
+the `@Rest` servlets.
diff --git a/todo/FINISHED-33-dynamic-rest-children.md
b/todo/FINISHED-33-dynamic-rest-children.md
new file mode 100644
index 0000000000..e14236a802
--- /dev/null
+++ b/todo/FINISHED-33-dynamic-rest-children.md
@@ -0,0 +1,65 @@
+# FINISHED-33: Dynamic add/remove of child REST resources
+
+**Status:** Shipped in 9.5.0.
+
+## What landed
+
+Runtime add/remove of child REST resources on any parent `RestContext`, with
ergonomic facades on `BasicRestServletGroup` / `BasicRestObjectGroup`.
+
+### API
+
+On `RestChildren`:
+
+```java
+public RestContext addChild(Class<?> resourceClass) throws ServletException;
+public RestContext addChild(Object resource) throws ServletException;
+public RestContext addChild(Object resource, boolean replace) throws
ServletException;
+public RestContext addChild(String path, Object resource) throws
ServletException;
+public RestContext addChild(String path, Object resource, boolean replace)
throws ServletException;
+public RestContext removeChild(String path);
+public RestContext removeChild(Class<?> resourceClass);
+```
+
+Convenience pass-throughs on `BasicRestServletGroup` and
`BasicRestObjectGroup`:
+
+```java
+public RestChildren getChildResources();
+public RestContext addChild(Class<?>);
+public RestContext addChild(Object);
+public RestContext addChild(String path, Object);
+public RestContext removeChild(String path);
+public RestContext removeChild(Class<?>);
+```
+
+### Concurrency model
+
+`RestChildren` now holds children in a `volatile` copy-on-write snapshot
(`Map<String,RestContext>` wrapped via `Collections.unmodifiableMap`). Route
matching (`findMatch(...)`) reads the snapshot once and iterates lock-free.
Mutations are serialized through an internal `writeLock`, build a fresh
`LinkedHashMap`, then atomically swap the snapshot. `asMap()` returns the
snapshot directly — no defensive copy.
+
+### Lifecycle
+
+- `addChild(...)` builds the child via the shared
`RestChildren.buildChildContext(...)` recipe (bean-store lookup or
`BeanInstantiator`, optional reflective `setContext`), then invokes
`postInit()` + `postInitChildFirst()` on the child before returning.
+- `removeChild(...)` calls `Servlet.destroy()` for Servlet-backed children
(which transitively calls `RestContext.destroy()` exactly once) or
`RestContext.destroy()` directly for non-Servlet children.
`RestContext.destroy()` recursively destroys grandchildren before closing its
own bean store.
+- Duplicate paths without `replace=true` throw `IllegalStateException`.
+
+## Files changed
+
+-
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestChildren.java`
— rewritten with volatile snapshot and the new public API; factored out
`buildChildContext(...)` for reuse by the static-init memoizer.
+-
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java`
+ - The `restChildren` memoizer now delegates to `RestChildren.create(this,
bs, servletConfig)` and `RestChildren.buildChildContext(...)`.
+ - Path resolution prioritises `Args.path` when non-empty, then falls back to
`@Rest(path)`.
+ - `postInit()` now uses `ClassInfo.getMethod(...)` (not `getPublicMethod`)
so it finds protected `setContext` declarations on subclasses of `RestServlet`.
+ - `destroy()` invokes `@RestDestroy` hooks, then recursively destroys
children (while the bean store is still open), then closes the bean store —
fixing a latent ordering bug uncovered by the new dynamic remove path.
+-
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestServletGroup.java`
— added `getChildResources()` + `addChild`/`removeChild` convenience methods;
Javadoc updated.
+-
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestObjectGroup.java`
— identical API for parity.
+-
`juneau-utest/src/test/java/org/apache/juneau/rest/BasicRestServletGroup_DynamicChildren_Test.java`
— 15 tests covering by-class / by-instance / path-override adds,
remove-by-path / remove-by-class, duplicate-path errors, replace semantics,
insertion order, lifecycle hooks, Servlet-typed child setContext +
Servlet.destroy, bean-store-backed instantiation, and a concurrent
reader/writer smoke test.
+- `juneau-docs/pages/topics/10.03.03.ChildResources.md` — new "Dynamic Child
Resources" section documenting the API and concurrency model.
+- `juneau-docs/pages/release-notes/9.5.0.md` — release-notes entry under
`juneau-rest-server`.
+
+## Coverage
+
+`RestChildren.java`: 88% branches / 93% instructions. Remaining gaps are
defensive paths (null-guards, exception wrappers) and the parent `destroy()`
walk that is not exercised by `MockRestClient` tests.
+
+## Verification
+
+- `mvn -pl juneau-utest test` — 50,121 tests pass, 0 failures, 0 errors.
+- `BasicRestServletGroup_DynamicChildren_Test` — all 15 tests pass.
diff --git a/todo/TODO-11-restclient-ng-coverage-closeout.md
b/todo/TODO-11-restclient-ng-coverage-closeout.md
new file mode 100644
index 0000000000..43e74f13b4
--- /dev/null
+++ b/todo/TODO-11-restclient-ng-coverage-closeout.md
@@ -0,0 +1,70 @@
+# TODO-11: RestClient NG — Coverage Closeout + Cross-Transport
Remote-Interface Tests
+
+> **Status:** Implementation, docs, and release notes for the next-generation
REST client and HTTP stack (`org.apache.juneau.ng.*`) have shipped. The
original design plan is archived in
`todo/FINISHED-11a-restclient-ng-design-plan.md`. This file tracks the
**remaining** cleanup before `TODO-11` can be fully retired.
+
+## Remaining work
+
+### A. Cross-transport remote-interface tests (blocked on `[TODO-31]`)
+
+Existing `org.apache.juneau.http.remote` tests cover remote-interface behavior
end-to-end but only against the classic `MockRestClient` — they never go
through any real wire transport. The new NG transports each have their own
basic coverage against `com.sun.net.httpserver.HttpServer` with canned
responses, but nothing exercises a Juneau remote proxy through a real Juneau
REST pipeline on the server side.
+
+Once `[TODO-31]` lands, build:
+
+- `MicroserviceTestFixture` in `juneau-utest` — JUnit 5 extension that boots a
`JettyMicroservice` on port 0 from one or more `@Configuration` classes and
exposes `getRootUrl()`. Tears down in `afterAll`.
+- `NgRemoteInterfaceTransport_Test` in `juneau-utest` — parameterized over the
five NG transports (`apache-hc45`, `apache-hc5`, `java-http`, `okhttp`,
`jetty`) via `@ParameterizedTest` + `@MethodSource`. Server side is a single
`@Rest` resource provided as a `@Bean` from a test `@Configuration`.
+
+Focused scenario set per transport (each scenario runs 5x — once per
transport):
+
+- GET with `@Path` (single + multi-segment) and `@Query` (single + map + bean).
+- POST with `@Content` (string, bean, `Reader`, `InputStream`).
+- POST with `@FormData` (single, map, bean).
+- Header propagation (`@Header` on parameter, default headers on the client).
+- Response status: 200 with body, 204 no-content, 404 -> remote exception
mapping.
+- One end-to-end RRPC scenario through `@Remote(rrpc=true)` to confirm
bidirectional bean marshalling.
+
+5 transports x ~15 scenarios = ~75 executions. This is the primary lift for
transport-module coverage.
+
+### B. `org.apache.juneau.ng.http` coverage closeout (independent — can land
before TODO-31)
+
+The bulk of the uncovered surface is in named response classes (`Ok`,
`Created`, `NotFound`, ...) and RFC-named header classes (`Accept`,
`ContentType`, ...). Cover with two parametric tests:
+
+-
`juneau-utest/src/test/java/org/apache/juneau/ng/http/response/NgNamedResponses_Test.java`
— reflectively enumerate every public subclass of the response base in
`org.apache.juneau.ng.http.response`; for each, invoke every public constructor
/ factory, call all public getters, run `writeTo(OutputStream)`, verify status
code matches the RFC code.
+-
`juneau-utest/src/test/java/org/apache/juneau/ng/http/header/NgNamedHeaders_Test.java`
— same parametric pattern over `org.apache.juneau.ng.http.header.*`. Walks
`of(...)` factories, value accessors, and `writeTo` / wire-format paths.
+- Fill targeted gaps in `HttpBody`, `HttpHeaders`, `HttpResource` discovered
by `./scripts/coverage.py --branches` (small one-shot tests).
+
+### C. Transport-module residual gaps (after A)
+
+After the parameterized suite from A has run, fill what remains via
`./scripts/coverage.py --branches`:
+
+- `close()` / connection-release hooks on each transport's response type.
+- Header carry-through for multi-value and quoted-value cases.
+- Streaming-body request paths (each transport's `InputStream` consumption
code).
+
+For hostile-server edge cases (truncated body, malformed status line), use
`com.sun.net.httpserver.HttpServer` following the existing pattern in
`ApacheHc45Transport_Test`.
+
+### D. Verify + archive
+
+- `./scripts/coverage.py --run` against
`juneau-rest/juneau-rest-common/src/main/java/org/apache/juneau/ng/http/`;
confirm >=95% instruction coverage.
+- `./scripts/coverage.py --branches` for every NG package; address remaining
gaps.
+- `./scripts/test.py` full suite; confirm no regressions and runtime stays in
the ~33s range.
+- Archive this file as `todo/FINISHED-11b-restclient-ng-coverage-closeout.md`
and remove `[TODO-11]` from `todo/TODO.md`.
+
+## Latest coverage measurement (May 2026)
+
+Measured via `./scripts/coverage.py` against `juneau-utest/target/jacoco.exec`.
+
+| Package / Module | Branches | Instructions |
+|---|---|---|
+| `org.apache.juneau.ng.http` (in `juneau-rest-common`) | 20% | **38%** |
+| `org.apache.juneau.ng.rest.client` (in `juneau-rest-client`) | 89% | 94% |
+| `org.apache.juneau.ng.rest.mock` (in `juneau-rest-mock`) | 98% | 96% |
+| Apache HC 4.5 transport (`juneau-ng-rest-client-apache-httpclient-45`) | 71%
| 81% |
+| Apache HC 5 transport (`juneau-ng-rest-client-apache-httpclient-50`) | 79% |
85% |
+| JDK `HttpClient` transport (`juneau-ng-rest-client-java-httpclient`) | 100%
| 78% |
+| OkHttp transport (`juneau-ng-rest-client-okhttp`) | 72% | 88% |
+| Jetty transport (`juneau-ng-rest-client-jetty`) | 75% | 77% |
+
+## Out of scope
+
+- Promoting the NG stack from beta to stable.
+- Deprecating or removing the classic `RestClient` / `juneau-rest-common`.
diff --git a/todo/TODO.md b/todo/TODO.md
index 750d8b1e7f..bab1163909 100644
--- a/todo/TODO.md
+++ b/todo/TODO.md
@@ -17,7 +17,7 @@
- [TODO-10] Move `org.apache.juneau.http.annotation` from `juneau-marshall`
into `juneau-rest-common` (already done for the annotation classes — plan
tracks remaining follow-on cleanup). See
`todo/TODO-10-move-http-annotation-to-rest-common.md`.
-- [TODO-11] Next-generation RestClient transport abstraction: decouple
`RestClient` from Apache HttpClient 4.5 so any HTTP transport can be plugged
in. See `todo/TODO-11-restclient2-transport-abstraction.md`.
+- [TODO-11] RestClient NG closeout: cross-transport remote-interface test
suite (blocked on [TODO-31]) plus NG http package coverage (independent).
Implementation has shipped (see
`todo/FINISHED-11a-restclient-ng-design-plan.md`). Active checklist in
`todo/TODO-11-restclient-ng-coverage-closeout.md`.
- [TODO-12] Schema validation mode for parsers and serializers: wire `@Schema`
validation into the bean property get/set lifecycle gated by a new
`validateSchema` flag on `MarshallingContext`. See
`todo/TODO-12-schema-validation.md`.
@@ -29,3 +29,5 @@
- [TODO-30] Investigate moving `ClassMeta` and related non-marshalling type
metadata from `juneau-marshall` into `juneau-commons` (analysis/feasibility
pass). See `todo/TODO-30-classmeta-to-commons.md`.
+- [TODO-32] YAML support in juneau-config: add a YAML-format alternative to
the existing INI-style Config. New `ConfigStore` implementation reading/writing
`.yml` / `.yaml` files with parity for sections, keys, defaults, comments, and
SVL interpolation; round-trips edits without losing comments where possible.
+