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 00cfa24f1b Complete TODO-72 @Rest mixins/paths composition + docs
00cfa24f1b is described below
commit 00cfa24f1bffe476e56aa69fb10e60b87514bd03
Author: James Bognar <[email protected]>
AuthorDate: Sat May 23 09:26:48 2026 -0400
Complete TODO-72 @Rest mixins/paths composition + docs
---
.../jetty/HealthProbeConfiguration.java | 53 +++++++
.../microservice/jetty/JettyServerComponent.java | 48 +++++--
.../java/org/apache/juneau/rest/RestContext.java | 77 +++++++---
.../java/org/apache/juneau/rest/RestOpContext.java | 25 +++-
.../java/org/apache/juneau/rest/RestOpInvoker.java | 18 ++-
.../apache/juneau/rest/RestServerConstants.java | 6 +
.../org/apache/juneau/rest/annotation/Rest.java | 45 ++++++
.../juneau/rest/annotation/RestAnnotation.java | 38 +++++
.../juneau/rest/health/BasicHealthResource.java | 160 +++++++++++++++++++++
.../java/org/apache/juneau/rest/health/Health.java | 135 +++++++++++++++++
.../apache/juneau/rest/health/HealthIndicator.java | 49 +++++++
.../org/apache/juneau/rest/health/HealthProbe.java | 31 ++++
.../juneau/rest/health/HealthProbeSettings.java | 76 ++++++++++
.../apache/juneau/rest/health/HealthStatus.java | 31 ++++
.../apache/juneau/rest/rrpc/RrpcRestOpContext.java | 16 +++
.../juneau/microservice/jetty/Rest_Paths_Test.java | 115 +++++++++++++++
.../juneau/rest/Rest_Mixins_Conflicts_Test.java | 49 +++++++
.../org/apache/juneau/rest/Rest_Mixins_Test.java | 79 ++++++++++
.../health/BasicHealthResource_AsMixin_Test.java | 64 +++++++++
.../rest/health/BasicHealthResource_Test.java | 69 +++++++++
.../juneau/rest/health/HealthIndicator_Test.java | 45 ++++++
...INISHED-65-health-readiness-liveness-probes.md} | 4 +-
todo/FINISHED-72-rest-mixins-and-paths.md | 25 ++++
todo/TODO.md | 3 +-
24 files changed, 1227 insertions(+), 34 deletions(-)
diff --git
a/juneau-microservice/juneau-microservice-jetty/src/main/java/org/apache/juneau/microservice/jetty/HealthProbeConfiguration.java
b/juneau-microservice/juneau-microservice-jetty/src/main/java/org/apache/juneau/microservice/jetty/HealthProbeConfiguration.java
new file mode 100644
index 0000000000..101290b38c
--- /dev/null
+++
b/juneau-microservice/juneau-microservice-jetty/src/main/java/org/apache/juneau/microservice/jetty/HealthProbeConfiguration.java
@@ -0,0 +1,53 @@
+/*
+ * 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 org.apache.juneau.commons.inject.*;
+import org.apache.juneau.rest.health.*;
+
+import jakarta.servlet.*;
+
+/**
+ * Opt-in probe configuration that auto-mounts {@link BasicHealthResource}.
+ *
+ * @since 9.5.0
+ */
+@Configuration
+public class HealthProbeConfiguration {
+
+ /**
+ * Default probe settings bean.
+ *
+ * @return Default settings.
+ */
+ @Bean
+ @ConditionalOnMissingBean(HealthProbeSettings.class)
+ public HealthProbeSettings healthProbeSettings() {
+ return HealthProbeSettings.create().build();
+ }
+
+ /**
+ * Probe servlet bean discovered by {@link JettyServerComponent}.
+ *
+ * @return Probe servlet.
+ */
+ @Bean(name="healthProbeServlet")
+ @ConditionalOnMissingBean(name="healthProbeServlet")
+ public Servlet healthProbeServlet() {
+ return new BasicHealthResource();
+ }
+}
diff --git
a/juneau-microservice/juneau-microservice-jetty/src/main/java/org/apache/juneau/microservice/jetty/JettyServerComponent.java
b/juneau-microservice/juneau-microservice-jetty/src/main/java/org/apache/juneau/microservice/jetty/JettyServerComponent.java
index 77458fd459..f4ddc29856 100644
---
a/juneau-microservice/juneau-microservice-jetty/src/main/java/org/apache/juneau/microservice/jetty/JettyServerComponent.java
+++
b/juneau-microservice/juneau-microservice-jetty/src/main/java/org/apache/juneau/microservice/jetty/JettyServerComponent.java
@@ -117,11 +117,22 @@ public class JettyServerComponent implements
MicroserviceListener {
return p;
}
- private static String restPathFor(Class<?> cls) {
- var r = cls.getAnnotation(Rest.class);
- if (r == null || r.path().isEmpty())
+ private static String normalizeExactPathSpec(String rawPath) {
+ var p = rawPath == null ? "" : trimTrailingSlashes(rawPath);
+ if (p.isEmpty())
return "/";
- return r.path();
+ if (! p.startsWith("/"))
+ p = "/" + p;
+ return p;
+ }
+
+ private static String[] restPathsFor(Class<?> cls) {
+ var r = cls.getAnnotation(Rest.class);
+ if (r == null)
+ return new String[] {"/*"};
+ if (r.paths().length > 0)
+ return
Arrays.stream(r.paths()).map(JettyServerComponent::normalizeExactPathSpec).toArray(String[]::new);
+ return new String[] {normalizePathSpec(r.path())};
}
/**
@@ -228,8 +239,11 @@ public class JettyServerComponent implements
MicroserviceListener {
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);
+ var pathSpecs = restPathsFor(cls);
+ var source = "@Bean " + cls.getName() +
(ne(e.getKey()) ? "[" + e.getKey() + "]" : "");
+ for (var pathSpec : pathSpecs)
+ checkPathCollision(pathSpec, source,
mountedPaths);
+ addServlet(servlet, pathSpecs);
}
if (env("juneau.serverPort").isEmpty())
@@ -281,10 +295,22 @@ public class JettyServerComponent implements
MicroserviceListener {
* @return This object.
*/
public JettyServerComponent addServlet(Servlet servlet, String
pathSpec) {
- var sh = new ServletHolder(servlet);
if (nn(pathSpec) && ! pathSpec.endsWith("/*"))
pathSpec = trimTrailingSlashes(pathSpec) + "/*";
- getServletContextHandler().addServlet(sh, pathSpec);
+ return addServlet(servlet, new String[] {pathSpec});
+ }
+
+ /**
+ * Adds an arbitrary servlet to this Jetty server at one or more
context paths.
+ *
+ * @param servlet The servlet instance.
+ * @param pathSpecs The context paths of the servlet.
+ * @return This object.
+ */
+ public JettyServerComponent addServlet(Servlet servlet,
String...pathSpecs) {
+ var sh = new ServletHolder(servlet);
+ for (var pathSpec : pathSpecs)
+ getServletContextHandler().addServlet(sh, pathSpec);
return this;
}
@@ -389,10 +415,14 @@ public class JettyServerComponent implements
MicroserviceListener {
private void mountWithCollisionCheck(Servlet servlet, String rawPath,
String source, Map<String,String> mountedPaths) {
var pathSpec = normalizePathSpec(rawPath);
+ checkPathCollision(pathSpec, source, mountedPaths);
+ addServlet(servlet, pathSpec);
+ }
+
+ private void checkPathCollision(String pathSpec, String source,
Map<String,String> mountedPaths) {
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);
}
}
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 958d58314d..2699073006 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
@@ -1133,7 +1133,24 @@ public class RestContext extends Context {
var b = RestOperations.create(bs);
var ap = getMarshallingContext().getAnnotationProvider();
var rci = ClassInfo.of(resource().get());
- for (var mi : rci.getPublicMethods()) {
+ var mixinInstances = new LinkedHashMap<Class<?>,Object>();
+ for (var mixinClass : getRestMixinClasses()) {
+ if (mixinClass == resourceClass())
+ continue;
+ var mixinResource =
mixinInstances.computeIfAbsent(mixinClass, x -> {
+ var y = bs.instantiate(x);
+ initializeResourceContext(y);
+ return y;
+ });
+ addRestOperationsForClass(b, ap,
ClassInfo.of(mixinClass), () -> mixinResource);
+ }
+ addRestOperationsForClass(b, ap, rci, this::getResource);
+ var override = bs.createBeanFromMethod(RestOperations.class,
resource().get(), RestContext::isBeanMethod, b).orElse(null);
+ return nn(override) ? override : b.build();
+ }));
+
+ private void addRestOperationsForClass(RestOperations.Builder b,
AnnotationProvider ap, ClassInfo classInfo, Supplier<Object> targetSupplier)
throws ServletException {
+ for (var mi : classInfo.getPublicMethods()) {
var al =
rstream(ap.find(mi)).filter(REST_OP_GROUP).collect(Collectors.toList());
if (al.isEmpty()) {
Predicate<MethodInfo> isRestAnnotatedInterface
= x -> x.getDeclaringClass().isInterface()
@@ -1143,22 +1160,43 @@ public class RestContext extends Context {
if (!al.isEmpty()) {
try {
if (mi.isNotPublic())
- throw servletException("@RestOp
method {0}.{1} must be defined as public.", rci.inner().getName(),
mi.getNameSimple());
- var roc = new RestOpContext(mi.inner(),
this);
+ throw servletException("@RestOp
method {0}.{1} must be defined as public.", classInfo.inner().getName(),
mi.getNameSimple());
+ var roc = new RestOpContext(mi.inner(),
this, targetSupplier);
if ("RRPC".equals(roc.getHttpMethod()))
{
- RestOpContext roc2 = new
RrpcRestOpContext(mi.inner(), this);
+ RestOpContext roc2 = new
RrpcRestOpContext(mi.inner(), this, targetSupplier);
b.add("GET", roc2).add("POST",
roc2);
} else {
b.add(roc);
}
} catch (Exception e) {
- throw servletException(e, "Problem
occurred trying to initialize methods on class {0}", rci.inner().getName());
+ throw servletException(e, "Problem
occurred trying to initialize methods on class {0}",
classInfo.inner().getName());
}
}
}
- var override = bs.createBeanFromMethod(RestOperations.class,
resource().get(), RestContext::isBeanMethod, b).orElse(null);
- return nn(override) ? override : b.build();
- }));
+ }
+
+ private LinkedHashSet<Class<?>> getRestMixinClasses() {
+ var out = new LinkedHashSet<Class<?>>();
+ var visited = new HashSet<Class<?>>();
+ getRestAnnotationsForProperty(PROPERTY_mixins).forEach(ai -> {
+ for (var mixin : ai.inner().mixins())
+ collectRestMixins(mixin, out, visited);
+ });
+ return out;
+ }
+
+ private void collectRestMixins(Class<?> mixin, LinkedHashSet<Class<?>>
out, Set<Class<?>> visited) {
+ if (mixin == null || mixin == resourceClass())
+ return;
+ if (!visited.add(mixin))
+ return;
+ out.add(mixin);
+ var r = mixin.getAnnotation(Rest.class);
+ if (r != null) {
+ for (var nested : r.mixins())
+ collectRestMixins(nested, out, visited);
+ }
+ }
/**
* The {@link RestChildren} for this resource — child {@link
RestContext} instances registered via
@@ -2659,17 +2697,7 @@ public class RestContext extends Context {
if (initialized.get())
return this;
var resource2 = getResource();
- // 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);
- } catch (ExecutableException e) {
- throw new ServletException(e.unwrap());
- }
- }
+ initializeResourceContext(resource2);
for (var x : postInitInvokerPair.get().invokers) {
try {
x.invoke(beanStore, getResource());
@@ -2681,6 +2709,17 @@ public class RestContext extends Context {
return this;
}
+ private void initializeResourceContext(Object resource2) {
+ var mi = ClassInfo.of(resource2).getMethod(x ->
x.hasName("setContext") && x.hasParameterTypes(RestContext.class)).orElse(null);
+ if (nn(mi)) {
+ try {
+ mi.accessible().invoke(resource2, this);
+ } catch (ExecutableException e) {
+ throw new RuntimeException(e.unwrap());
+ }
+ }
+ }
+
/**
* Called during servlet initialization to invoke all {@link
RestPostInit} child-first methods.
*
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpContext.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpContext.java
index c31e7b37f0..80366089e4 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpContext.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpContext.java
@@ -33,6 +33,7 @@ import java.nio.charset.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.Consumer;
+import java.util.function.Supplier;
import java.util.stream.*;
import org.apache.juneau.*;
import org.apache.juneau.commons.annotation.*;
@@ -115,11 +116,17 @@ public class RestOpContext extends Context implements
Comparable<RestOpContext>
private Method restMethod;
private RestContext restContext;
+ private Supplier<Object> restResourceSupplier;
Builder(Method method, RestContext context) {
+ this(method, context, null);
+ }
+
+ Builder(Method method, RestContext context, Supplier<Object>
resourceSupplier) {
this.restContext = context;
this.restMethod = method;
+ this.restResourceSupplier = resourceSupplier == null ?
context::getResource : resourceSupplier;
var ap =
context.getMarshallingContext().getAnnotationProvider();
var mi = MethodInfo.of(context.getResourceClass(),
method);
@@ -164,6 +171,7 @@ public class RestOpContext extends Context implements
Comparable<RestOpContext>
protected final Method method;
protected final MethodInfo mi;
protected final RestContext context;
+ private final Supplier<Object> resourceSupplier;
// The annotation work-list produced during construction.
private final AnnotationWorkList appliedAnnotations;
@@ -174,7 +182,7 @@ public class RestOpContext extends Context implements
Comparable<RestOpContext>
private AnnotationWorkList appliedAnnotations() { return
appliedAnnotations; }
private BeanStore beanStore() { return context.getBeanStore(); }
private WritableBeanStore opBeanStore() { return opBeanStore; }
- private Object resource() { return context.getResource(); }
+ private Object resource() { return resourceSupplier.get(); }
private VarResolver varResolver() { return context.getVarResolver(); }
//-----------------------------------------------------------------------------------------------------------------
@@ -588,7 +596,7 @@ public class RestOpContext extends Context implements
Comparable<RestOpContext>
/** The invoker for the operation method itself. */
private final Memoizer<RestOpInvoker> methodInvoker = memoizer(() ->
- new RestOpInvoker(method(),
restContext().findRestOperationArgs(method(), opBeanStore()),
restContext().getMethodExecStats(method()))
+ new RestOpInvoker(method(),
restContext().findRestOperationArgs(method(), opBeanStore()),
restContext().getMethodExecStats(method()), this::resource)
);
/** The effective max-input byte limit for this operation. */
@@ -1094,6 +1102,18 @@ public class RestOpContext extends Context implements
Comparable<RestOpContext>
this(new Builder(method, context));
}
+ /**
+ * 3-arg positional context constructor.
+ *
+ * @param method The Java method this context represents. Must not be
<jk>null</jk>.
+ * @param context The owning {@link RestContext}. Must not be
<jk>null</jk>.
+ * @param resourceSupplier Supplier that returns the invocation target
for this operation.
+ * @throws ServletException If context could not be created.
+ */
+ public RestOpContext(java.lang.reflect.Method method, RestContext
context, Supplier<Object> resourceSupplier) throws ServletException {
+ this(new Builder(method, context, resourceSupplier));
+ }
+
/**
* Context constructor.
*
@@ -1109,6 +1129,7 @@ public class RestOpContext extends Context implements
Comparable<RestOpContext>
context = builder.restContext;
method = builder.restMethod;
+ resourceSupplier = builder.restResourceSupplier;
mi = MethodInfo.of(method).accessible();
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpInvoker.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpInvoker.java
index 7db661807e..b861f213f2 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpInvoker.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpInvoker.java
@@ -19,6 +19,7 @@ package org.apache.juneau.rest;
import static org.apache.juneau.commons.utils.Utils.*;
import java.lang.reflect.*;
+import java.util.function.*;
import org.apache.juneau.commons.reflect.*;
import org.apache.juneau.http.response.*;
@@ -35,6 +36,7 @@ import org.apache.juneau.rest.stats.*;
public class RestOpInvoker extends MethodInvoker {
private final RestOpArg[] opArgs;
+ private final Supplier<Object> resourceSupplier;
/**
* Constructor.
@@ -44,8 +46,21 @@ public class RestOpInvoker extends MethodInvoker {
* @param stats The instrumentor.
*/
public RestOpInvoker(Method m, RestOpArg[] opArgs, MethodExecStats
stats) {
+ this(m, opArgs, stats, null);
+ }
+
+ /**
+ * Constructor.
+ *
+ * @param m The method being wrapped.
+ * @param opArgs The parameter resolvers.
+ * @param stats The instrumentor.
+ * @param resourceSupplier Optional resource supplier. When
<jk>null</jk>, falls back to {@link RestSession#getResource()}.
+ */
+ public RestOpInvoker(Method m, RestOpArg[] opArgs, MethodExecStats
stats, Supplier<Object> resourceSupplier) {
super(m, stats);
this.opArgs = opArgs;
+ this.resourceSupplier = resourceSupplier;
}
/**
@@ -71,7 +86,8 @@ public class RestOpInvoker extends MethodInvoker {
RestRequest req = opSession.getRequest();
RestResponse res = opSession.getResponse();
- Object output = super.invoke(session.getResource(),
args);
+ var target = resourceSupplier == null ?
session.getResource() : resourceSupplier.get();
+ Object output = super.invoke(target, args);
// Handle manual call to req.setDebug().
Boolean debug =
req.getAttribute("Debug").as(Boolean.class).orElse(null);
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java
index 998e9b6e90..103cd9f84e 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java
@@ -204,6 +204,12 @@ public final class RestServerConstants {
/** The {@code "path"} annotation attribute name — used in {@code
noInherit} matching on {@code @RestOp} / verb annotations. */
public static final String PROPERTY_path = "path";
+ /** The {@code "paths"} annotation attribute name — used in {@code
noInherit} matching on {@code @Rest} annotations to cut off the class-chain
walk when resolving top-level multi-mount path specs. */
+ public static final String PROPERTY_paths = "paths";
+
+ /** The {@code "mixins"} annotation attribute name — used in {@code
noInherit} matching on {@code @Rest} annotations to cut off the class-chain
walk when resolving operation mixins. */
+ public static final String PROPERTY_mixins = "mixins";
+
/** The {@code "value"} annotation attribute name — used by {@code
@RestOp}/verb annotations to hold the (optional method-prefixed) path; folded
into {@link #PROPERTY_path}. */
public static final String PROPERTY_value = "value";
}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/Rest.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/Rest.java
index 74d62b2820..90f0082bd7 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/Rest.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/Rest.java
@@ -185,6 +185,28 @@ public @interface Rest {
*/
Class<?>[] children() default {};
+ /**
+ * REST mixins.
+ *
+ * <p>
+ * Defines operation-provider classes whose {@link RestOp
@RestOp}-group methods should be composed into this
+ * resource.
+ *
+ * <p>
+ * Mixin methods are discovered the same way as local operation methods
and share this resource's
+ * {@link RestContext} configuration. On path/method collisions, local
methods on this resource win over mixin
+ * methods.
+ *
+ * <h5 class='section'>Inheritance Rules</h5>
+ * <ul>
+ * <li>Mixins on child are combined with those on parent class.
+ * <li>Mixins are listed parent-to-child in the order they appear
in the annotation.
+ * </ul>
+ *
+ * @return The annotation value.
+ */
+ Class<?>[] mixins() default {};
+
/**
* Client version header.
*
@@ -1088,6 +1110,29 @@ public @interface Rest {
*/
String path() default "";
+ /**
+ * Additional servlet mount paths.
+ *
+ * <p>
+ * Optional multi-mount companion to {@link #path()} for top-level
servlet deployment.
+ *
+ * <p>
+ * When specified, servlet containers may mount this resource on each
listed path.
+ * This is primarily intended for built-in support endpoints such as
health probes where multiple exact URLs
+ * should be served by a single servlet instance.
+ *
+ * <h5 class='section'>Notes:</h5><ul>
+ * <li class='note'>
+ * Paths are normalized to servlet path-specs by the
hosting runtime.
+ * <li class='note'>
+ * When both {@link #path()} and {@link #paths()} are
present, runtimes may use {@link #paths()} for
+ * top-level mounting and continue using {@link #path()}
for child-resource composition.
+ * </ul>
+ *
+ * @return The annotation value.
+ */
+ String[] paths() default {};
+
/**
* Default path parameter definitions.
*
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestAnnotation.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestAnnotation.java
index bbf0c56b7b..10c6f0eafa 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestAnnotation.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestAnnotation.java
@@ -76,6 +76,7 @@ public class RestAnnotation {
private Class<? extends DebugEnablement> debugEnablement =
DebugEnablement.Void.class;
private Class<? extends Serializer>[] serializers = new
Class[0];
private Class<?>[] children = {};
+ private Class<?>[] mixins = {};
private Class<?>[] parsers = {};
private Swagger swagger = SwaggerAnnotation.DEFAULT;
private String disableContentParam = "";
@@ -94,6 +95,7 @@ public class RestAnnotation {
private String maxInput = "";
private String messages = "";
private String path = "";
+ private String[] paths = {};
private String problemDetails = "";
private String renderResponseStackTraces = "";
private String roleGuard = "";
@@ -199,6 +201,17 @@ public class RestAnnotation {
return this;
}
+ /**
+ * Sets the {@link Rest#mixins()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder mixins(Class<?>...value) {
+ mixins = value;
+ return this;
+ }
+
/**
* Sets the {@link Rest#clientVersionHeader()} property on this
annotation.
*
@@ -499,6 +512,17 @@ public class RestAnnotation {
return this;
}
+ /**
+ * Sets the {@link Rest#paths()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder paths(String...value) {
+ paths = value;
+ return this;
+ }
+
/**
* Sets the {@link Rest#produces()} property on this annotation.
*
@@ -734,6 +758,7 @@ public class RestAnnotation {
private final Class<? extends DebugEnablement> debugEnablement;
private final Class<? extends Serializer>[] serializers;
private final Class<?>[] children;
+ private final Class<?>[] mixins;
private final Class<?>[] parsers;
private final Swagger swagger;
private final String disableContentParam;
@@ -752,6 +777,7 @@ public class RestAnnotation {
private final String maxInput;
private final String messages;
private final String path;
+ private final String[] paths;
private final String problemDetails;
private final String renderResponseStackTraces;
private final String roleGuard;
@@ -784,6 +810,7 @@ public class RestAnnotation {
allowedMethodParams = b.allowedMethodParams;
callLogger = b.callLogger;
children = copyOf(b.children);
+ mixins = copyOf(b.mixins);
clientVersionHeader = b.clientVersionHeader;
config = b.config;
eagerInit = b.eagerInit;
@@ -810,6 +837,7 @@ public class RestAnnotation {
partParser = b.partParser;
partSerializer = b.partSerializer;
path = b.path;
+ paths = copyOf(b.paths);
problemDetails = b.problemDetails;
produces = copyOf(b.produces);
renderResponseStackTraces = b.renderResponseStackTraces;
@@ -860,6 +888,11 @@ public class RestAnnotation {
return children;
}
+ @Override /* Overridden from Rest */
+ public Class<?>[] mixins() {
+ return mixins;
+ }
+
@Override /* Overridden from Rest */
public String clientVersionHeader() {
return clientVersionHeader;
@@ -1005,6 +1038,11 @@ public class RestAnnotation {
return path;
}
+ @Override /* Overridden from Rest */
+ public String[] paths() {
+ return paths;
+ }
+
@Override /* Overridden from Rest */
public Path[] pathParams() {
return pathParams;
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/health/BasicHealthResource.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/health/BasicHealthResource.java
new file mode 100644
index 0000000000..f95c1c3c45
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/health/BasicHealthResource.java
@@ -0,0 +1,160 @@
+/*
+ * 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.health;
+
+import static java.util.concurrent.TimeUnit.*;
+import static org.apache.juneau.commons.utils.StringUtils.*;
+
+import java.time.*;
+import java.util.*;
+import java.util.Map.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.servlet.*;
+
+/**
+ * Built-in health/readiness/liveness probe resource.
+ *
+ * @since 9.5.0
+ */
+@Rest(paths={"/healthz","/readyz","/livez"})
+public class BasicHealthResource extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Health probe endpoint.
+ *
+ * @param res The response.
+ * @return Aggregated health payload.
+ */
+ @RestGet(path="/healthz")
+ public HealthResponse healthz(RestResponse res) {
+ return aggregate(null, res);
+ }
+
+ /**
+ * Readiness probe endpoint.
+ *
+ * @param res The response.
+ * @return Aggregated health payload.
+ */
+ @RestGet(path="/readyz")
+ public HealthResponse readyz(RestResponse res) {
+ return aggregate(HealthProbe.READY, res);
+ }
+
+ /**
+ * Liveness probe endpoint.
+ *
+ * @param res The response.
+ * @return Aggregated health payload.
+ */
+ @RestGet(path="/livez")
+ public HealthResponse livez(RestResponse res) {
+ return aggregate(HealthProbe.LIVE, res);
+ }
+
+ private HealthResponse aggregate(HealthProbe probe, RestResponse res) {
+ var out = new LinkedHashMap<String,ComponentHealth>();
+ var timeout =
getContext().getBeanStore().getBean(HealthProbeSettings.class).map(HealthProbeSettings::getTimeout).orElse(Duration.ofSeconds(1));
+ var timeoutMillis = Math.max(1L, timeout.toMillis());
+
+ for (var e : indicators().entrySet()) {
+ var indicator = e.getValue();
+ if (probe != null &&
!indicator.probes().contains(probe))
+ continue;
+ var componentName = firstNonEmpty(e.getKey(),
indicator.getClass().getSimpleName());
+ var component = runIndicator(componentName, indicator,
timeoutMillis);
+ out.put(componentName, component);
+ }
+
+ var status = summarize(out.values());
+ res.setStatus(status == HealthStatus.DOWN ? 503 : 200);
+ return new HealthResponse(status, out);
+ }
+
+ /**
+ * Returns indicators to evaluate for this request.
+ *
+ * @return Indicator map keyed by bean name.
+ */
+ protected Map<String,HealthIndicator> indicators() {
+ return
getContext().getBeanStore().getBeansOfType(HealthIndicator.class);
+ }
+
+ private static ComponentHealth runIndicator(String name,
HealthIndicator indicator, long timeoutMillis) {
+ var future = CompletableFuture.supplyAsync(indicator::check);
+ try {
+ var h = future.get(timeoutMillis, MILLISECONDS);
+ return ComponentHealth.from(h);
+ } catch (TimeoutException e) {
+ future.cancel(true);
+ return ComponentHealth.from(Health.down(name,
e).detail("error", "Health check timed out after " + timeoutMillis +
"ms").build());
+ } catch (ExecutionException e) {
+ return ComponentHealth.from(Health.down(name,
e.getCause()).build());
+ } catch (Throwable e) {
+ return ComponentHealth.from(Health.down(name,
e).build());
+ }
+ }
+
+ private static HealthStatus summarize(Collection<ComponentHealth>
components) {
+ var hasUnknown = false;
+ for (var c : components) {
+ if (c.status == HealthStatus.DOWN)
+ return HealthStatus.DOWN;
+ if (c.status == HealthStatus.UNKNOWN)
+ hasUnknown = true;
+ }
+ return hasUnknown ? HealthStatus.UNKNOWN : HealthStatus.UP;
+ }
+
+ /**
+ * Probe response payload.
+ */
+ public static class HealthResponse {
+ private final HealthStatus status;
+ private final Map<String,ComponentHealth> components;
+ HealthResponse(HealthStatus status, Map<String,ComponentHealth>
components) {
+ this.status = status;
+ this.components = components;
+ }
+ public HealthStatus getStatus() { return status; }
+ public Map<String,ComponentHealth> getComponents() { return
components; }
+ }
+
+ /**
+ * Component payload in the response.
+ */
+ public static class ComponentHealth {
+ private final HealthStatus status;
+ private final Map<String,Object> details;
+ ComponentHealth(HealthStatus status, Map<String,Object>
details) {
+ this.status = status;
+ this.details = details;
+ }
+ static ComponentHealth from(Health h) {
+ var details = new LinkedHashMap<>(h.getDetails());
+ if (h.getError() != null)
+ details.putIfAbsent("error",
h.getError().toString());
+ return new ComponentHealth(h.getStatus(), details);
+ }
+ public HealthStatus getStatus() { return status; }
+ public Map<String,Object> getDetails() { return details; }
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/health/Health.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/health/Health.java
new file mode 100644
index 0000000000..11411d9681
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/health/Health.java
@@ -0,0 +1,135 @@
+/*
+ * 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.health;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.util.*;
+
+/**
+ * Component health result returned from a {@link HealthIndicator}.
+ *
+ * @since 9.5.0
+ */
+public final class Health {
+
+ private final String name;
+ private final HealthStatus status;
+ private final Map<String,Object> details;
+ private final Throwable error;
+
+ private Health(Builder b) {
+ this.name = b.name;
+ this.status = b.status;
+ this.details = Collections.unmodifiableMap(new
LinkedHashMap<>(b.details));
+ this.error = b.error;
+ }
+
+ /**
+ * Creates an {@link HealthStatus#UP} builder.
+ *
+ * @param name Component name.
+ * @return A new builder.
+ */
+ public static Builder up(String name) {
+ return new Builder(name, HealthStatus.UP, null);
+ }
+
+ /**
+ * Creates an {@link HealthStatus#DOWN} builder.
+ *
+ * @param name Component name.
+ * @param error Optional error causing the down state.
+ * @return A new builder.
+ */
+ public static Builder down(String name, Throwable error) {
+ return new Builder(name, HealthStatus.DOWN, error);
+ }
+
+ /**
+ * Creates an {@link HealthStatus#UNKNOWN} builder.
+ *
+ * @param name Component name.
+ * @return A new builder.
+ */
+ public static Builder unknown(String name) {
+ return new Builder(name, HealthStatus.UNKNOWN, null);
+ }
+
+ /** @return Component name. */
+ public String getName() { return name; }
+ /** @return Health status. */
+ public HealthStatus getStatus() { return status; }
+ /** @return Structured details map. */
+ public Map<String,Object> getDetails() { return details; }
+ /** @return Optional error. */
+ public Throwable getError() { return error; }
+
+ /**
+ * Builder for {@link Health}.
+ */
+ public static final class Builder {
+ private final String name;
+ private final HealthStatus status;
+ private final Map<String,Object> details = new
LinkedHashMap<>();
+ private Throwable error;
+
+ private Builder(String name, HealthStatus status, Throwable
error) {
+ assertArgNotNull("name", name);
+ if (name.isEmpty())
+ throw new IllegalArgumentException("Argument
'name' cannot be empty.");
+ this.name = name;
+ this.status = Objects.requireNonNull(status, "status");
+ this.error = error;
+ }
+
+ /**
+ * Adds a structured detail.
+ *
+ * @param key Detail key.
+ * @param value Detail value.
+ * @return This builder.
+ */
+ public Builder detail(String key, Object value) {
+ assertArgNotNull("key", key);
+ if (key.isEmpty())
+ throw new IllegalArgumentException("Argument
'key' cannot be empty.");
+ details.put(key, value);
+ return this;
+ }
+
+ /**
+ * Overrides the error attached to this result.
+ *
+ * @param value The throwable.
+ * @return This builder.
+ */
+ public Builder error(Throwable value) {
+ error = value;
+ return this;
+ }
+
+ /**
+ * Builds a health result.
+ *
+ * @return A new health result.
+ */
+ public Health build() {
+ return new Health(this);
+ }
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/health/HealthIndicator.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/health/HealthIndicator.java
new file mode 100644
index 0000000000..1d074b18b4
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/health/HealthIndicator.java
@@ -0,0 +1,49 @@
+/*
+ * 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.health;
+
+import static java.util.EnumSet.*;
+
+import java.util.*;
+
+/**
+ * Functional SPI for reporting health of one component.
+ *
+ * @since 9.5.0
+ */
+@FunctionalInterface
+public interface HealthIndicator {
+
+ /**
+ * Performs the health check.
+ *
+ * @return Health result. Never <jk>null</jk>.
+ */
+ Health check();
+
+ /**
+ * Probe categories where this indicator should run.
+ *
+ * <p>
+ * Default is liveness + readiness.
+ *
+ * @return Probe categories. Never <jk>null</jk>.
+ */
+ default EnumSet<HealthProbe> probes() {
+ return of(HealthProbe.LIVE, HealthProbe.READY);
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/health/HealthProbe.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/health/HealthProbe.java
new file mode 100644
index 0000000000..db83a24e18
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/health/HealthProbe.java
@@ -0,0 +1,31 @@
+/*
+ * 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.health;
+
+/**
+ * Probe categories supported by the built-in health probe resource.
+ *
+ * @since 9.5.0
+ */
+public enum HealthProbe {
+ /** Liveness probe. */
+ LIVE,
+ /** Readiness probe. */
+ READY,
+ /** Startup probe. */
+ STARTUP
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/health/HealthProbeSettings.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/health/HealthProbeSettings.java
new file mode 100644
index 0000000000..9f645cc700
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/health/HealthProbeSettings.java
@@ -0,0 +1,76 @@
+/*
+ * 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.health;
+
+import java.time.*;
+
+/**
+ * Settings for the built-in health probe resource.
+ *
+ * @since 9.5.0
+ */
+public class HealthProbeSettings {
+
+ private final Duration timeout;
+
+ private HealthProbeSettings(Builder b) {
+ this.timeout = b.timeout;
+ }
+
+ /**
+ * Builder creator.
+ *
+ * @return New builder.
+ */
+ public static Builder create() {
+ return new Builder();
+ }
+
+ /**
+ * @return Per-indicator timeout.
+ */
+ public Duration getTimeout() {
+ return timeout;
+ }
+
+ /**
+ * Builder for {@link HealthProbeSettings}.
+ */
+ public static class Builder {
+ private Duration timeout = Duration.ofSeconds(1);
+
+ /**
+ * Sets per-indicator timeout.
+ *
+ * @param value Timeout value.
+ * @return This object.
+ */
+ public Builder timeout(Duration value) {
+ timeout = value == null ? Duration.ofSeconds(1) : value;
+ return this;
+ }
+
+ /**
+ * Builds settings.
+ *
+ * @return Settings.
+ */
+ public HealthProbeSettings build() {
+ return new HealthProbeSettings(this);
+ }
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/health/HealthStatus.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/health/HealthStatus.java
new file mode 100644
index 0000000000..4271c0cadf
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/health/HealthStatus.java
@@ -0,0 +1,31 @@
+/*
+ * 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.health;
+
+/**
+ * Status values returned by {@link HealthIndicator} checks.
+ *
+ * @since 9.5.0
+ */
+public enum HealthStatus {
+ /** Component is healthy. */
+ UP,
+ /** Component is unhealthy. */
+ DOWN,
+ /** Component health is unknown or not yet determined. */
+ UNKNOWN
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/rrpc/RrpcRestOpContext.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/rrpc/RrpcRestOpContext.java
index 5229cd0fbd..5148adced2 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/rrpc/RrpcRestOpContext.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/rrpc/RrpcRestOpContext.java
@@ -17,6 +17,7 @@
package org.apache.juneau.rest.rrpc;
import java.lang.reflect.*;
+import java.util.function.*;
import org.apache.juneau.http.remote.RrpcInterfaceMeta;
import org.apache.juneau.http.response.*;
@@ -51,7 +52,22 @@ public class RrpcRestOpContext extends RestOpContext {
*/
public RrpcRestOpContext(Method method, RestContext context) throws
ServletException {
super(method, context);
+ var interfaceClass =
getMarshallingContext().getClassMeta(getJavaMethod().getGenericReturnType());
+ meta = new RrpcInterfaceMeta(interfaceClass.inner(), null);
+ if (meta.getMethodsByPath().isEmpty())
+ throw new InternalServerError("Method {0} returns an
interface {1} that doesn't define any remote methods.",
getJavaMethod().getName(), interfaceClass.getNameFull());
+ }
+ /**
+ * Constructor.
+ *
+ * @param method The Java method this context represents. Must not be
<jk>null</jk>.
+ * @param context The owning {@link RestContext}. Must not be
<jk>null</jk>.
+ * @param resourceSupplier Supplier that returns the invocation target
for this operation.
+ * @throws ServletException If context could not be created.
+ */
+ public RrpcRestOpContext(Method method, RestContext context,
Supplier<Object> resourceSupplier) throws ServletException {
+ super(method, context, resourceSupplier);
var interfaceClass =
getMarshallingContext().getClassMeta(getJavaMethod().getGenericReturnType());
meta = new RrpcInterfaceMeta(interfaceClass.inner(), null);
if (meta.getMethodsByPath().isEmpty())
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/microservice/jetty/Rest_Paths_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/microservice/jetty/Rest_Paths_Test.java
new file mode 100644
index 0000000000..bddb41e286
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/microservice/jetty/Rest_Paths_Test.java
@@ -0,0 +1,115 @@
+/*
+ * 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 java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.microservice.*;
+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.*;
+
+class Rest_Paths_Test extends TestBase {
+
+ private static Microservice create(Class<?>... configurations) throws
Exception {
+ var classes = new Class<?>[configurations.length + 1];
+ System.arraycopy(configurations, 0, classes, 0,
configurations.length);
+ classes[configurations.length] = JettyConfiguration.class;
+ return Microservice.create().configurations(classes).build();
+ }
+
+ private static Server newServer() {
+ var server = new Server();
+ var connector = new ServerConnector(server);
+ connector.setPort(0);
+ server.addConnector(connector);
+ var ctx = new ServletContextHandler();
+ ctx.setContextPath("/");
+ server.setAttribute("ServletContextHandler", ctx);
+ server.setHandler(ctx);
+ return server;
+ }
+
+ @Rest(paths={"/h1","/h2"})
+ public static class A_PathsServlet extends RestServlet {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @Configuration
+ static class A_Config {
+ @Bean Server jettyServer() { return newServer(); }
+ @Bean Servlet pathsServlet() { return new A_PathsServlet(); }
+ }
+
+ @Test void a01_restPathsMountServletAtAllDeclaredPaths() throws
Exception {
+ var ms = create(A_Config.class);
+ try {
+ ms.start();
+ var ctx =
ms.getBeanStore().getBean(JettyServerComponent.class).orElseThrow().getServletContextHandler();
+ var mapped = new HashSet<String>();
+ for (var mapping :
ctx.getServletHandler().getServletMappings())
+
mapped.addAll(Arrays.asList(mapping.getPathSpecs()));
+ assertTrue(mapped.contains("/h1"), "Expected /h1 to be
mounted");
+ assertTrue(mapped.contains("/h2"), "Expected /h2 to be
mounted");
+ } finally {
+ ms.stop();
+ }
+ }
+
+ @Rest(paths={"/same"})
+ public static class B_FirstServlet extends RestServlet {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @Rest(paths={"/same"})
+ public static class B_SecondServlet extends RestServlet {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @Configuration
+ static class B_CollisionConfig {
+ @Bean Server jettyServer() { return newServer(); }
+ @Bean Servlet first() { return new B_FirstServlet(); }
+ @Bean(name="second") Servlet second() { return new
B_SecondServlet(); }
+ }
+
+ @Test void a02_duplicateRestPathsFailFast() throws Exception {
+ var ms = create(B_CollisionConfig.class);
+ try {
+ var ex = assertThrows(Exception.class, ms::start);
+ var root = rootCause(ex);
+ assertTrue(root.getMessage().contains("Servlet mount
path collision"), root.getMessage());
+ } finally {
+ ms.stop();
+ }
+ }
+
+ private static Throwable rootCause(Throwable t) {
+ var x = t;
+ while (x.getCause() != null && x.getCause() != x)
+ x = x.getCause();
+ return x;
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/Rest_Mixins_Conflicts_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/Rest_Mixins_Conflicts_Test.java
new file mode 100644
index 0000000000..cd627de6ad
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/Rest_Mixins_Conflicts_Test.java
@@ -0,0 +1,49 @@
+/*
+ * 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 org.apache.juneau.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+class Rest_Mixins_Conflicts_Test extends TestBase {
+
+ @Rest
+ public static class A_Mixin {
+ @RestGet(path="/same")
+ public String sameFromMixin() {
+ return "mixin";
+ }
+ }
+
+ @Rest(mixins={A_Mixin.class})
+ public static class A extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @RestGet(path="/same")
+ public String sameFromResource() {
+ return "resource";
+ }
+ }
+
+ @Test void a01_resourceMethodWinsOnCollision() throws Exception {
+ var c = MockRestClient.buildLax(A.class);
+
c.get("/same").accept("application/json").run().assertStatus(200).assertContent().asString().isContains("\"resource\"");
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/Rest_Mixins_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/Rest_Mixins_Test.java
new file mode 100644
index 0000000000..d9bafece75
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/Rest_Mixins_Test.java
@@ -0,0 +1,79 @@
+/*
+ * 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 org.apache.juneau.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+class Rest_Mixins_Test extends TestBase {
+
+ @Rest
+ public static class A_MixinB {
+ @RestGet(path="/b")
+ public String b() {
+ return "b";
+ }
+ }
+
+ @Rest(mixins={A_MixinB.class})
+ public static class A_MixinA {
+ @RestGet(path="/a")
+ public String a() {
+ return "a";
+ }
+ }
+
+ @Rest(mixins={A_MixinA.class})
+ public static class A extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @RestGet(path="/root")
+ public String root() {
+ return "root";
+ }
+ }
+
+ @Rest
+ public static class B_MixinC {
+ @RestGet(path="/c")
+ public String c() {
+ return "c";
+ }
+ }
+
+ @Rest(mixins={A_MixinA.class,B_MixinC.class})
+ public static class B extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @Test void a01_mixinsExposeOperations() throws Exception {
+ var c = MockRestClient.buildLax(A.class);
+
c.get("/root").accept("application/json").run().assertStatus(200).assertContent().asString().isContains("\"root\"");
+
c.get("/a").accept("application/json").run().assertStatus(200).assertContent().asString().isContains("\"a\"");
+
c.get("/b").accept("application/json").run().assertStatus(200).assertContent().asString().isContains("\"b\"");
+ }
+
+ @Test void a02_multipleMixinsExposeOperations() throws Exception {
+ var c = MockRestClient.buildLax(B.class);
+
c.get("/a").accept("application/json").run().assertStatus(200).assertContent().asString().isContains("\"a\"");
+
c.get("/b").accept("application/json").run().assertStatus(200).assertContent().asString().isContains("\"b\"");
+
c.get("/c").accept("application/json").run().assertStatus(200).assertContent().asString().isContains("\"c\"");
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/health/BasicHealthResource_AsMixin_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/health/BasicHealthResource_AsMixin_Test.java
new file mode 100644
index 0000000000..3418163d72
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/health/BasicHealthResource_AsMixin_Test.java
@@ -0,0 +1,64 @@
+/*
+ * 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.health;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import static java.util.EnumSet.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+class BasicHealthResource_AsMixin_Test extends TestBase {
+
+ @Rest(mixins={BasicHealthResource.class})
+ public static class A extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @Bean(name="db")
+ public HealthIndicator dbIndicator() {
+ return new HealthIndicator() {
+ @Override public Health check() { return
Health.up("db").detail("ok", true).build(); }
+ @Override public EnumSet<HealthProbe> probes()
{ return of(HealthProbe.LIVE); }
+ };
+ }
+
+ @Bean(name="cache")
+ public HealthIndicator cacheIndicator() {
+ return new HealthIndicator() {
+ @Override public Health check() { return
Health.down("cache", new IllegalStateException("offline")).build(); }
+ @Override public EnumSet<HealthProbe> probes()
{ return of(HealthProbe.READY); }
+ };
+ }
+ }
+
+ @Test void a01_healthEndpointsResolveViaMixin() throws Exception {
+ var c = MockRestClient.buildLax(A.class);
+ var r =
c.get("/healthz").accept("application/json").run().cacheContent();
+ if (r.getStatusCode() != 503)
+ fail("Expected 503 but got " + r.getStatusCode() + "
with body: " + r.getContent().asString());
+ r.assertContent().asString().isContains("\"status\":\"DOWN\"");
+
c.get("/livez").accept("application/json").run().assertStatus(200);
+
c.get("/readyz").accept("application/json").run().assertStatus(503);
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/health/BasicHealthResource_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/health/BasicHealthResource_Test.java
new file mode 100644
index 0000000000..2e0063f82e
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/health/BasicHealthResource_Test.java
@@ -0,0 +1,69 @@
+/*
+ * 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.health;
+
+import static java.util.EnumSet.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.junit.jupiter.api.*;
+
+class BasicHealthResource_Test extends TestBase {
+
+ @Rest
+ public static class A extends BasicHealthResource {
+ @Override
+ protected Map<String,HealthIndicator> indicators() {
+ return Map.of(
+ "db", (HealthIndicator)() ->
Health.up("db").detail("validationQueryMs", 12).build(),
+ "cache", (HealthIndicator)() ->
Health.down("cache", new IllegalStateException("offline")).build()
+ );
+ }
+ }
+
+ @Rest
+ public static class B extends BasicHealthResource {
+ @Override
+ protected Map<String,HealthIndicator> indicators() {
+ return Map.of(
+ "liveOnly", new HealthIndicator() {
+ @Override public Health check() {
return Health.up("liveOnly").build(); }
+ @Override public EnumSet<HealthProbe>
probes() { return of(HealthProbe.LIVE); }
+ },
+ "readyOnly", new HealthIndicator() {
+ @Override public Health check() {
return Health.up("readyOnly").build(); }
+ @Override public EnumSet<HealthProbe>
probes() { return of(HealthProbe.READY); }
+ }
+ );
+ }
+ }
+
+ @Test void a01_healthzReturns503WhenAnyComponentDown() throws Exception
{
+ var c =
MockRestClient.create(A.class).ignoreErrors().json().build();
+ c.get("/healthz").run().assertStatus(503)
+
.assertContent().asString().isContains("\"status\":\"DOWN\"");
+ }
+
+ @Test void a02_livezAndReadyzFilterByProbeType() throws Exception {
+ var c = MockRestClient.buildLax(B.class);
+
c.get("/livez").run().assertStatus(200).assertContent().asString().isContains("liveOnly");
+
c.get("/readyz").run().assertStatus(200).assertContent().asString().isContains("readyOnly");
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/health/HealthIndicator_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/health/HealthIndicator_Test.java
new file mode 100644
index 0000000000..b2707edaab
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/health/HealthIndicator_Test.java
@@ -0,0 +1,45 @@
+/*
+ * 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.health;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.*;
+
+class HealthIndicator_Test {
+
+ @Test void a01_builderUpContainsDetails() {
+ var h = Health.up("db").detail("latencyMs", 12).build();
+ assertEquals("db", h.getName());
+ assertEquals(HealthStatus.UP, h.getStatus());
+ assertEquals(12, h.getDetails().get("latencyMs"));
+ }
+
+ @Test void a02_builderDownCarriesError() {
+ var ex = new IllegalStateException("boom");
+ var h = Health.down("cache", ex).build();
+ assertEquals(HealthStatus.DOWN, h.getStatus());
+ assertSame(ex, h.getError());
+ }
+
+ @Test void a03_defaultProbesAreLiveAndReady() {
+ HealthIndicator i = () -> Health.up("ok").build();
+ assertTrue(i.probes().contains(HealthProbe.LIVE));
+ assertTrue(i.probes().contains(HealthProbe.READY));
+ assertFalse(i.probes().contains(HealthProbe.STARTUP));
+ }
+}
diff --git a/todo/TODO-65-health-readiness-liveness-probes.md
b/todo/FINISHED-65-health-readiness-liveness-probes.md
similarity index 89%
rename from todo/TODO-65-health-readiness-liveness-probes.md
rename to todo/FINISHED-65-health-readiness-liveness-probes.md
index 9d04d643ce..7aebcd2705 100644
--- a/todo/TODO-65-health-readiness-liveness-probes.md
+++ b/todo/FINISHED-65-health-readiness-liveness-probes.md
@@ -1,4 +1,6 @@
-# TODO-65: Health / readiness / liveness probe endpoints + `HealthIndicator`
SPI
+# FINISHED-65: Health / readiness / liveness probe endpoints +
`HealthIndicator` SPI
+
+**Completed 2026-05-22.** Landed a new health-probe stack across
`juneau-rest-server` and `juneau-microservice-jetty`:
`org.apache.juneau.rest.health` now includes `HealthIndicator`, `Health`,
`HealthStatus`, `HealthProbe`, `HealthProbeSettings`, and `BasicHealthResource`
endpoints (`/healthz`, `/readyz`, `/livez`) with aggregate response payloads
and `503` on any `DOWN`; `HealthProbeConfiguration` wires the servlet/settings
via `@Configuration` for microservice opt-in, with per-indicato [...]
Source: split out of TODO-18 brainstorm on 2026-05-22.
diff --git a/todo/FINISHED-72-rest-mixins-and-paths.md
b/todo/FINISHED-72-rest-mixins-and-paths.md
new file mode 100644
index 0000000000..1ad066de6b
--- /dev/null
+++ b/todo/FINISHED-72-rest-mixins-and-paths.md
@@ -0,0 +1,25 @@
+# FINISHED-72 - `@Rest(mixins=...)` composition + `@Rest(paths=...)`
multi-mount
+
+Completed 2026-05-23. Landed new `@Rest(mixins=...)` operation composition and
`@Rest(paths=...)` top-level multi-mount support, refactored
`BasicHealthResource` to explicit probe paths (`/healthz`, `/readyz`,
`/livez`), and eliminated root `/*` collisions for health probe deployment
while preserving an explicit standalone-mount escape hatch.
+
+## Goal
+
+Resolve `BasicHealthResource` root-path collisions (`/*`) while adding a
reusable resource-composition primitive for REST operations.
+
+## Phase 0 findings
+
+- `RestContext.restOperations` is the exact method-discovery seam
(`RestContext.java`, `restOperations` memoizer).
+- `RestOpInvoker` always invoked on `RestSession.getResource()`, so mixins
required an explicit invocation target supplier.
+- `JettyServerComponent` auto-mount path logic was single-path (`restPathFor`
+ `mountWithCollisionCheck`), so multi-mount required a path array abstraction
plus one-holder/multi-pattern registration.
+
+## Implementation checklist
+
+- [x] Add `@Rest(mixins=Class<?>[])`.
+- [x] Add `@Rest(paths=String[])`.
+- [x] Wire both through `RestAnnotation` and constants.
+- [x] Update `JettyServerComponent` auto-discovery to honor `paths`.
+- [x] Add mixin operation discovery in `RestContext`.
+- [x] Add mixin invocation target support in `RestOpContext` / `RestOpInvoker`
(including RRPC variant).
+- [x] Refactor `BasicHealthResource` to
`@Rest(paths={"/healthz","/readyz","/livez"})`.
+- [x] Add and update tests for paths, mixins, conflicts, and health-as-mixin.
+- [x] Update release notes + health topic + migration guide docs.
diff --git a/todo/TODO.md b/todo/TODO.md
index 3ba3250d0d..92fc2e5069 100644
--- a/todo/TODO.md
+++ b/todo/TODO.md
@@ -6,8 +6,6 @@
- [TODO-37] - Agent instruction consolidation.
-- [TODO-65] Health / readiness / liveness probe endpoints + `HealthIndicator`
SPI. See `todo/TODO-65-health-readiness-liveness-probes.md`.
-
- [TODO-66] Rate-limit guard + request-id propagation filter. See
`todo/TODO-66-rate-limit-and-request-id.md`.
- [TODO-67] Observability hooks — Micrometer + OpenTelemetry seams via
`MethodExecStats`. See `todo/TODO-67-observability-micrometer-otel.md`.
@@ -19,3 +17,4 @@
- [TODO-70] `CompletableFuture<?>` return-type support + optional
virtual-thread per-request dispatch. See
`todo/TODO-70-async-completablefuture-virtual-threads.md`.
- [TODO-71] Move doc site updates from a github hook to a script that gets
executed locally. Change docusaurus search functionality to
@easyops-cn/docusaurus-search-local.
+