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. 
+

Reply via email to