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 8eceb21bb5 TODO-145 closeout: route-index content negotiation + mixin 
host class-config inheritance
8eceb21bb5 is described below

commit 8eceb21bb5b91bc2c182fe5b63dc9fac9d464db0
Author: James Bognar <[email protected]>
AuthorDate: Mon Jun 1 13:11:11 2026 -0400

    TODO-145 closeout: route-index content negotiation + mixin host 
class-config inheritance
    
    Two pieces of the TODO-145 REST class-family closeout:
    
    TODO-123: The route-index op now returns a RouteDescriptions POJO
    (org.apache.juneau.rest.beans.RouteDescriptions / RouteDescription) instead 
of
    writing JSON directly, so the listing is properly content-negotiated and 
renders
    as HTML/JSON/XML per the request Accept header.
    
    TODO-148: Mixin ops now inherit the host's class-level @ContextApply config
    (including @HtmlDocConfig) through a gated helper in RestContext /
    RestOpContext.Builder, with precedence method > mixin-class > host-class and
    honoring @Rest(noInherit). A companion fix lets a mixin inherit the host's 
raw
    Config so $C{...} SVL variables resolve. With host class-config inheritance 
in
    place, the concrete getChildren override workaround on 
BasicRestServletGroup,
    BasicRestResourceGroup, and BasicSpringRestServletGroup was removed in 
favor of
    @Rest(mixins=NavigationMixin.class).
    
    Adds RouteIndexMixin_ContentNegotiation_Test and 
MixinHtmlDocInheritance_Test.
---
 .../springboot/BasicSpringRestServletGroup.java    |  34 +---
 .../java/org/apache/juneau/rest/RestContext.java   |  42 +++++
 .../java/org/apache/juneau/rest/RestOpContext.java |   8 +
 .../apache/juneau/rest/beans/RouteDescription.java | 174 +++++++++++++++++++++
 .../juneau/rest/beans/RouteDescriptions.java       |  64 ++++++++
 .../apache/juneau/rest/ops/RouteIndexMixin.java    |  68 ++++----
 .../rest/servlet/BasicRestResourceGroup.java       |  33 +---
 .../juneau/rest/servlet/BasicRestServletGroup.java |  33 +---
 .../juneau/rest/ops/BasicOps_ParentChain_Test.java |   7 +-
 .../rest/ops/MixinHtmlDocInheritance_Test.java     | 166 ++++++++++++++++++++
 .../rest/ops/RouteIndexMixin_AsMixin_Test.java     |  28 ++--
 .../RouteIndexMixin_ContentNegotiation_Test.java   | 101 ++++++++++++
 .../ops/RouteIndexMixin_SvlPathOverride_Test.java  |  27 ++--
 juneau-utest/test-run-history.tsv                  |   1 +
 14 files changed, 653 insertions(+), 133 deletions(-)

diff --git 
a/juneau-rest/juneau-rest-server-springboot/src/main/java/org/apache/juneau/rest/springboot/BasicSpringRestServletGroup.java
 
b/juneau-rest/juneau-rest-server-springboot/src/main/java/org/apache/juneau/rest/springboot/BasicSpringRestServletGroup.java
index c1f0e8e9a5..5de485e9e1 100644
--- 
a/juneau-rest/juneau-rest-server-springboot/src/main/java/org/apache/juneau/rest/springboot/BasicSpringRestServletGroup.java
+++ 
b/juneau-rest/juneau-rest-server-springboot/src/main/java/org/apache/juneau/rest/springboot/BasicSpringRestServletGroup.java
@@ -16,9 +16,7 @@
  */
 package org.apache.juneau.rest.springboot;
 
-import org.apache.juneau.rest.*;
 import org.apache.juneau.rest.annotation.*;
-import org.apache.juneau.rest.beans.*;
 import org.apache.juneau.rest.config.*;
 import org.apache.juneau.rest.ops.*;
 
@@ -33,14 +31,13 @@ import org.apache.juneau.rest.ops.*;
  * for details.
  *
  * <p>
- * Adds the group-navigation endpoint ({@code GET /}) as a concrete method on 
top of the residual op-mixins
- * inherited from {@link BasicSpringRestServlet}. The navigation page is 
rendered as a method of the host
- * resource (rather than the {@link NavigationMixin} sub-context mixin) so it 
inherits the host's
- * {@link org.apache.juneau.html.annotation.HtmlDocConfig @HtmlDocConfig} page 
decoration &mdash; a mixin
- * sub-context does not inherit the host's class-level {@code @HtmlDocConfig}, 
so a navigation op living on a
- * mixin renders without the host's navlinks/aside/footer/theme.  {@link 
NavigationMixin} (backed by
- * {@link org.apache.juneau.rest.servlet.RestMixin#getHostContext()}) is the 
mixin flavor for hosts where that
- * page decoration is not required (e.g. JSON-only APIs).
+ * Adds the group-navigation endpoint ({@code GET /}) by composing {@link 
NavigationMixin} via
+ * {@link Rest#mixins() @Rest(mixins=...)} on top of the residual op-mixins 
inherited from
+ * {@link BasicSpringRestServlet}. The navigation op lives on the {@code 
NavigationMixin} sub-context (backed
+ * by {@link org.apache.juneau.rest.servlet.RestMixin#getHostContext()}) but 
renders with the host's
+ * {@link org.apache.juneau.html.annotation.HtmlDocConfig @HtmlDocConfig} page 
decoration &mdash; mixin
+ * sub-contexts inherit the host's class-level {@code @HtmlDocConfig} (and 
other class-level config), so the
+ * navigation page matches the host's other endpoints.
  *
  * <p>
  * Children are attached to this resource using the {@link Rest#children() 
@Rest(children)} annotation.
@@ -52,25 +49,10 @@ import org.apache.juneau.rest.ops.*;
  *
  * @serial exclude
  */
-@Rest
+@Rest(mixins=NavigationMixin.class)
 @SuppressWarnings({
        "java:S110" // Inheritance depth acceptable for 
BasicSpringRestServletGroup hierarchy
 })
 public abstract class BasicSpringRestServletGroup extends 
BasicSpringRestServlet {
        private static final long serialVersionUID = 1L;
-
-       /**
-        * [GET /] - Get child resources.
-        *
-        * <p>
-        * Returns a bean that lists and allows navigation to child resources. 
Default implementation
-        * delegates to {@link ChildResourceDescriptions#of(RestRequest)}; 
subclasses may override.
-        *
-        * @param req The HTTP request.
-        * @return The bean containing links to the child resources.
-        */
-       @RestGet(path="/", summary="Navigation page")
-       public ChildResourceDescriptions getChildren(RestRequest req) {
-               return ChildResourceDescriptions.of(req);
-       }
 }
\ No newline at end of file
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 3a4bfcf601..ecd0ee19eb 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
@@ -27,6 +27,7 @@ import org.apache.juneau.commons.inject.WritableBeanStore;
 
 import static jakarta.servlet.http.HttpServletResponse.*;
 import static java.util.Collections.*;
+import static org.apache.juneau.commons.reflect.AnnotationTraversal.*;
 import static org.apache.juneau.commons.reflect.ReflectionUtils.*;
 import static org.apache.juneau.commons.utils.AssertionUtils.*;
 import static org.apache.juneau.commons.utils.CollectionUtils.*;
@@ -977,6 +978,13 @@ public class RestContext extends Context {
                var cf = cfv.orElse("");
                if (v.isEmpty() && "SYSTEM_DEFAULT".equals(cf))
                        v.set(Config.getSystemDefault());
+               if (v.isEmpty() && cf.isEmpty() && isMixinContextField() && 
getParentContext() != null)
+                       // Mixin sub-contexts with no own @Rest(config) inherit 
the host's raw Config, so that $C{...}
+                       // SVL variables embedded in inherited class-level 
config (e.g. the @HtmlDocConfig theme/header/
+                       // footer declared via $C{REST/...} on BasicRestConfig) 
resolve against the host's loaded config
+                       // rather than an empty one.  This mirrors the way 
messages and varResolver tokens already inherit
+                       // from the host for mixin sub-contexts.
+                       v.set(getParentContext().rawConfig.get());
                if (v.isEmpty()) {
                        Config.Builder cb = Config.create().varResolver(vr);
                        if (!cf.isEmpty())
@@ -2814,6 +2822,40 @@ public class RestContext extends Context {
                return rstream(annotations.subList(0, cutoff));
        }
 
+       /**
+        * For a {@linkplain #isMixinContext() mixin sub-context}, returns the 
<b>host</b> resource class's
+        * class-level annotation infos (host class chain, in parent-to-child 
order) so a mixin operation can
+        * inherit the host's class-level {@link 
org.apache.juneau.annotation.ContextApply @ContextApply} config
+        * (e.g. {@link org.apache.juneau.html.annotation.HtmlDocConfig 
@HtmlDocConfig},
+        * {@link org.apache.juneau.serializer.annotation.SerializerConfig 
@SerializerConfig}).
+        *
+        * <p>
+        * Used by {@link RestOpContext.Builder} to prepend the host's 
class-level config annotations <i>ahead of</i>
+        * the mixin class's own class annotations in the op's annotation 
work-list, so the effective precedence is
+        * method &gt; mixin-class &gt; host-class.  This is the 
page-decoration counterpart of the context-level
+        * inheritance walk in {@link #getRestAnnotationsForProperty(String)}: 
that walk inherits {@code @Rest}
+        * properties (serializers, parsers, guards, ...) from the host, while 
this method inherits standalone
+        * class-level config annotations that are not {@code @Rest} attributes.
+        *
+        * <p>
+        * Inheritance is automatic for every mixin sub-context.  A mixin can 
opt out of inheriting a specific host
+        * config annotation by naming its annotation type in {@link 
Rest#noInherit() @Rest(noInherit)} on the mixin
+        * class (e.g. {@code @Rest(noInherit={"HtmlDocConfig"})}), reusing the 
same {@code noInherit} resolution that
+        * gates the context-level walk so the opt-out is uniform.  For 
non-mixin contexts (top-level resources and
+        * child resources) this returns an empty stream &mdash; only mixin ops 
inherit from a composition host.
+        *
+        * @param ap The annotation provider to resolve the host class chain 
with.
+        * @return The host class chain's annotation infos in parent-to-child 
order, or an empty stream when this is
+        *      not a mixin sub-context (or has no parent).
+        */
+       Stream<AnnotationInfo<?>> 
getInheritedHostClassAnnotations(AnnotationProvider ap) {
+               if (! isMixinContextField() || parentContext == null)
+                       return Stream.empty();
+               var blocked = noInherit.get();
+               return 
rstream(ap.find(ClassInfo.of(parentContext.getResourceClass()), SELF, PARENTS))
+                       .filter(ai -> ! 
blocked.contains(ai.annotationType().getSimpleName()));
+       }
+
        /**
         * Returns all {@link Rest} annotations on the resource class 
hierarchy, in child-to-parent order.
         *
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 0329657efb..4e2dbee78c 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
@@ -143,9 +143,17 @@ public class RestOpContext extends Context implements 
Comparable<RestOpContext>
                                // Parent-to-child merge: class-level 
annotations (parent class chain) applied first, then method-level
                                // (matching-methods chain, return type, 
package). Method annotations thus override class annotations
                                // for the same property. LinkedHashSet 
deduplicates while preserving order.
+                               //
+                               // For a mixin op (the op's context is a mixin 
sub-context), the host's class-level @ContextApply
+                               // config (e.g. @HtmlDocConfig, 
@SerializerConfig) is prepended ahead of the mixin class's own class
+                               // annotations, so the effective precedence is 
method > mixin-class > host-class. This is the
+                               // op-level counterpart of the context-level 
inheritance walk and is gated by @Rest(noInherit) on
+                               // the mixin. For non-mixin contexts the host 
stream is empty (no behavior change).
+                               var hostClassAnnotations = 
context.getInheritedHostClassAnnotations(ap);
                                var declaringClassAnnotations = 
rstream(ap.find(resourceClass, SELF, PARENTS));
                                var methodAnnotations = rstream(ap.find(mi, 
SELF, MATCHING_METHODS, RETURN_TYPE, PACKAGE));
                                var allAnnotationsSet = new 
java.util.LinkedHashSet<AnnotationInfo<?>>();
+                               
hostClassAnnotations.forEach(allAnnotationsSet::add);
                                
declaringClassAnnotations.forEach(allAnnotationsSet::add);
                                
methodAnnotations.forEach(allAnnotationsSet::add);
                                var work = AnnotationWorkList.of(vrs, 
allAnnotationsSet.stream().filter(CONTEXT_APPLY_FILTER));
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/beans/RouteDescription.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/beans/RouteDescription.java
new file mode 100644
index 0000000000..aa6a63d5c6
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/beans/RouteDescription.java
@@ -0,0 +1,174 @@
+/*
+ * 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.beans;
+
+import java.util.*;
+
+import org.apache.juneau.commons.annotation.Schema;
+import org.apache.juneau.commons.bean.*;
+import org.apache.juneau.html.annotation.*;
+import org.apache.juneau.http.annotation.*;
+
+/**
+ * Describes a single route (an {@code @RestOp}-annotated method) in a 
route-index listing.
+ *
+ * <p>
+ * Companion of {@link ResourceDescription} (which describes a child 
resource); a {@code RouteDescription}
+ * describes an operation mounted on a resource.  Returned (as a {@link 
RouteDescriptions} list) by the
+ * route-index op so the response is content-negotiated through the configured 
serializers &mdash; an
+ * {@code Accept: text/html} request renders a browsable table with the 
{@linkplain #getPath() path} as a
+ * clickable link (via {@link Html#link() @Html(link)}), while {@code 
application/json} / {@code text/xml}
+ * clients receive the same entries in their requested format.
+ *
+ * <p>
+ * The five serialized properties &mdash; {@code path}, {@code methods}, 
{@code summary}, {@code description},
+ * and {@code deprecated} &mdash; preserve the JSON shape emitted by earlier 
route-index snapshots so existing
+ * API clients see an equivalent payload.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='jc'>{@link RouteDescriptions}
+ *     <li class='jc'>{@link ResourceDescription}
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/UtilityBeans";>Utility Beans</a>
+ * </ul>
+ *
+ * @serial exclude
+ * @since 9.5.0
+ */
+@BeanType(properties = "path,methods,summary,description,deprecated", 
findFluentSetters = true)
+@Response(schema = @Schema(ignore = true))
+public class RouteDescription {
+
+       private String path;
+       private List<String> methods;
+       private String summary;
+       private String description;
+       private boolean deprecated;
+
+       /** No-arg bean constructor. */
+       public RouteDescription() {}
+
+       /**
+        * Constructor.
+        *
+        * @param path The mount path of the operation (e.g. {@code 
"/items/{id}"}).
+        * @param methods The HTTP methods the operation responds to (e.g. 
{@code ["GET"]}).
+        * @param summary The operation summary, or an empty string when none 
is declared.
+        * @param description The operation description, or an empty string 
when none is declared.
+        * @param deprecated Whether the operation (or its declaring class) is 
{@link Deprecated @Deprecated}.
+        */
+       public RouteDescription(String path, List<String> methods, String 
summary, String description, boolean deprecated) {
+               this.path = path;
+               this.methods = methods;
+               this.summary = summary;
+               this.description = description;
+               this.deprecated = deprecated;
+       }
+
+       /**
+        * Returns the mount path of the operation.
+        *
+        * <p>
+        * Rendered as a clickable link in HTML output via {@link Html#link() 
@Html(link)}.
+        *
+        * @return The mount path.
+        */
+       @Html(link = "servlet:{path}")
+       public String getPath() { return path; }
+
+       /**
+        * Sets the mount path of the operation.
+        *
+        * @param value The new value.
+        * @return This object.
+        */
+       public RouteDescription path(String value) {
+               path = value;
+               return this;
+       }
+
+       /**
+        * Returns the HTTP methods the operation responds to.
+        *
+        * @return The HTTP methods.
+        */
+       public List<String> getMethods() { return methods; }
+
+       /**
+        * Sets the HTTP methods the operation responds to.
+        *
+        * @param value The new value.
+        * @return This object.
+        */
+       public RouteDescription methods(List<String> value) {
+               methods = value;
+               return this;
+       }
+
+       /**
+        * Returns the operation summary.
+        *
+        * @return The summary, or an empty string when none is declared.
+        */
+       public String getSummary() { return summary; }
+
+       /**
+        * Sets the operation summary.
+        *
+        * @param value The new value.
+        * @return This object.
+        */
+       public RouteDescription summary(String value) {
+               summary = value;
+               return this;
+       }
+
+       /**
+        * Returns the operation description.
+        *
+        * @return The description, or an empty string when none is declared.
+        */
+       public String getDescription() { return description; }
+
+       /**
+        * Sets the operation description.
+        *
+        * @param value The new value.
+        * @return This object.
+        */
+       public RouteDescription description(String value) {
+               description = value;
+               return this;
+       }
+
+       /**
+        * Returns whether the operation is deprecated.
+        *
+        * @return <jk>true</jk> if the operation (or its declaring class) is 
{@link Deprecated @Deprecated}.
+        */
+       public boolean isDeprecated() { return deprecated; }
+
+       /**
+        * Sets whether the operation is deprecated.
+        *
+        * @param value The new value.
+        * @return This object.
+        */
+       public RouteDescription deprecated(boolean value) {
+               deprecated = value;
+               return this;
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/beans/RouteDescriptions.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/beans/RouteDescriptions.java
new file mode 100644
index 0000000000..16f342c558
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/beans/RouteDescriptions.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.beans;
+
+import java.util.*;
+
+/**
+ * A list of {@link RouteDescription} objects.
+ *
+ * <p>
+ * Typically returned by a route-index op so the listing is content-negotiated 
through the configured
+ * serializers (browsable HTML table, JSON, or XML based on the {@code Accept} 
header), mirroring the way
+ * {@link ChildResourceDescriptions} backs the child-resource navigation page.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='jc'>{@link RouteDescription}
+ *     <li class='jc'>{@link ResourceDescriptions}
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/UtilityBeans";>Utility Beans</a>
+ * </ul>
+ *
+ * @serial exclude
+ * @since 9.5.0
+ */
+public class RouteDescriptions extends ArrayList<RouteDescription> {
+       private static final long serialVersionUID = 1L;
+
+       /**
+        * Static creator.
+        *
+        * @return A new {@link RouteDescriptions} object.
+        */
+       public static RouteDescriptions create() {
+               return new RouteDescriptions();
+       }
+
+       /**
+        * Adds a new {@link RouteDescription} to this list.
+        *
+        * @param path The mount path of the operation.
+        * @param methods The HTTP methods the operation responds to.
+        * @param summary The operation summary.
+        * @param description The operation description.
+        * @param deprecated Whether the operation is deprecated.
+        * @return This object.
+        */
+       public RouteDescriptions append(String path, List<String> methods, 
String summary, String description, boolean deprecated) {
+               super.add(new RouteDescription(path, methods, summary, 
description, deprecated));
+               return this;
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/RouteIndexMixin.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/RouteIndexMixin.java
index 136daf8f4c..079fa35804 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/RouteIndexMixin.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/RouteIndexMixin.java
@@ -16,18 +16,25 @@
  */
 package org.apache.juneau.rest.ops;
 
-import java.io.*;
 import java.lang.annotation.*;
 import java.lang.reflect.Method;
 import java.util.*;
 
-import org.apache.juneau.json.*;
 import org.apache.juneau.rest.*;
 import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.beans.*;
 
 /**
- * Mixin that emits a JSON index of all {@link RestOp @RestOp}-annotated 
methods on the host
- * resource (and any other mixins on it) at {@code /options} (configurable).
+ * Mixin that exposes a content-negotiated index of all {@link RestOp 
@RestOp}-annotated methods on the
+ * host resource (and any other mixins on it) at {@code /options} 
(configurable).
+ *
+ * <p>
+ * The op returns a {@link RouteDescriptions} POJO rather than writing a fixed 
format, so Juneau's content
+ * negotiation serves a browsable HTML page (with clickable route links), 
JSON, or XML based on the request
+ * {@code Accept} header &mdash; mirroring the way {@code 
NavigationMixin.getChildren(...)} produces a
+ * navigation page while still serving JSON to API clients. The host therefore 
needs serializers configured
+ * (e.g. via {@code BasicUniversalConfig} / a {@code Basic*} servlet base); a 
bare {@code @Rest} host with no
+ * serializers cannot content-negotiate the listing.
  *
  * <p>
  * Sibling of {@link EchoMixin} ({@code /echo/*}) and {@link AdminMixin}
@@ -80,7 +87,8 @@ import org.apache.juneau.rest.annotation.*;
  *
  * <p>
  * Each {@code @RestOp}-annotated method on the host (and on any other mixins 
on the host) is
- * surfaced as a single entry; the request returns a JSON list ordered by path:
+ * surfaced as a single entry, ordered by path. Rendered as JSON (for an 
{@code Accept: application/json}
+ * request) the listing looks like:
  *
  * <p class='bjson'>
  *     [
@@ -134,24 +142,27 @@ public class RouteIndexMixin {
        public RouteIndexMixin() {}
 
        /**
-        * [GET /options] &mdash; emit the route index as a JSON list.
+        * [GET /options] &mdash; return the route index as a 
content-negotiated POJO.
+        *
+        * <p>
+        * Returns a {@link RouteDescriptions} list rather than writing a fixed 
format, so the response is
+        * content-negotiated through the host's configured serializers: an 
{@code Accept: text/html} request
+        * renders a browsable table (with each {@linkplain 
RouteDescription#getPath() path} as a clickable link),
+        * while {@code application/json} / {@code text/xml} clients receive 
the same entries in their requested
+        * format &mdash; mirroring the way {@code 
NavigationMixin.getChildren(...)} backs the child-resource
+        * navigation page.
         *
         * @param req The current REST request &mdash; supplies the host {@link 
RestContext}.
-        * @param res The current REST response.
-        * @throws IOException If an I/O error occurs while writing the 
response.
+        * @return The route-index entries, ordered by path.
         */
        @RestGet(
                path="/#{pathToken(${juneau.routeindex.path:options})}",
                summary="Route index",
-               description="JSON list of @RestOp-annotated methods on the host 
(excluding hidden / ops endpoints).",
+               description="Content-negotiated list of @RestOp-annotated 
methods on the host (excluding hidden / ops endpoints).",
                swagger=@OpSwagger(ignore=true)
        )
-       public void getRoutes(RestRequest req, RestResponse res) throws 
IOException {
-               var hostCtx = resolveHostContext(req.getContext());
-               var entries = collect(hostCtx);
-               try (var w = res.getDirectWriter("application/json")) {
-                       JsonSerializer.DEFAULT_READABLE.serialize(entries, w);
-               }
+       public RouteDescriptions getRoutes(RestRequest req) {
+               return collect(resolveHostContext(req.getContext()));
        }
 
        private static RestContext resolveHostContext(RestContext c) {
@@ -168,9 +179,9 @@ public class RouteIndexMixin {
         * @param hostCtx The host context. Must not be {@code null}.
         * @return A list of route-index entries, ordered by path.
         */
-       public List<Map<String,Object>> collect(RestContext hostCtx) {
+       public RouteDescriptions collect(RestContext hostCtx) {
                var seen = new HashSet<Method>();
-               var entries = new ArrayList<Map<String,Object>>();
+               var entries = new RouteDescriptions();
                for (var oc : hostCtx.getRestOperations().getOpContexts())
                        addEntry(entries, seen, oc);
                for (var mixinCtx : hostCtx.getMixinContexts().values())
@@ -180,7 +191,7 @@ public class RouteIndexMixin {
                return entries;
        }
 
-       private static void addEntry(List<Map<String,Object>> entries, 
Set<Method> seen, RestOpContext oc) {
+       private static void addEntry(RouteDescriptions entries, Set<Method> 
seen, RestOpContext oc) {
                var m = oc.getJavaMethod();
                if (m == null || ! seen.add(m))
                        return;
@@ -188,14 +199,13 @@ public class RouteIndexMixin {
                        return;
                if (isSelfHandler(m))
                        return;
-               var entry = new LinkedHashMap<String,Object>();
-               entry.put("path", oc.getPathPattern());
-               entry.put("methods", List.of(oc.getHttpMethod()));
-               entry.put("summary", readSummary(m));
-               entry.put("description", readDescription(m));
-               entry.put("deprecated", m.isAnnotationPresent(Deprecated.class)
-                       || 
m.getDeclaringClass().isAnnotationPresent(Deprecated.class));
-               entries.add(entry);
+               entries.append(
+                       oc.getPathPattern(),
+                       List.of(oc.getHttpMethod()),
+                       readSummary(m),
+                       readDescription(m),
+                       m.isAnnotationPresent(Deprecated.class) || 
m.getDeclaringClass().isAnnotationPresent(Deprecated.class)
+               );
        }
 
        @SuppressWarnings("java:S3776") // Cognitive-complexity: linear walk 
over a small annotation list; splitting hurts JIT.
@@ -257,10 +267,10 @@ public class RouteIndexMixin {
                return "";
        }
 
-       private static int compareByPathThenMethod(Map<String,Object> a, 
Map<String,Object> b) {
-               var c = 
String.valueOf(a.get("path")).compareTo(String.valueOf(b.get("path")));
+       private static int compareByPathThenMethod(RouteDescription a, 
RouteDescription b) {
+               var c = 
String.valueOf(a.getPath()).compareTo(String.valueOf(b.getPath()));
                if (c != 0)
                        return c;
-               return 
String.valueOf(a.get("methods")).compareTo(String.valueOf(b.get("methods")));
+               return 
String.valueOf(a.getMethods()).compareTo(String.valueOf(b.getMethods()));
        }
 }
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestResourceGroup.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestResourceGroup.java
index 8ea399bc49..bf9db9ab21 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestResourceGroup.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestResourceGroup.java
@@ -18,7 +18,6 @@ package org.apache.juneau.rest.servlet;
 
 import org.apache.juneau.rest.*;
 import org.apache.juneau.rest.annotation.*;
-import org.apache.juneau.rest.beans.*;
 import org.apache.juneau.rest.config.*;
 import org.apache.juneau.rest.ops.*;
 
@@ -36,14 +35,13 @@ import jakarta.servlet.http.*;
  * for details.
  *
  * <p>
- * Adds the group-navigation endpoint ({@code GET /}) as a concrete method on 
top of the residual op-mixins
- * inherited from {@link BasicRestResource}. The navigation page is rendered 
as a method of the host resource
- * (rather than the {@link NavigationMixin} sub-context mixin) so it inherits 
the host's
- * {@link org.apache.juneau.html.annotation.HtmlDocConfig @HtmlDocConfig} page 
decoration &mdash; a mixin
- * sub-context does not inherit the host's class-level {@code @HtmlDocConfig}, 
so a navigation op living on a
- * mixin renders without the host's navlinks/aside/footer/theme.  {@link 
NavigationMixin} (backed by
- * {@link RestMixin#getHostContext()}) is the mixin flavor for hosts where 
that page decoration is not
- * required (e.g. JSON-only APIs).
+ * Adds the group-navigation endpoint ({@code GET /}) by composing {@link 
NavigationMixin} via
+ * {@link Rest#mixins() @Rest(mixins=...)} on top of the residual op-mixins 
inherited from
+ * {@link BasicRestResource}. The navigation op lives on the {@code 
NavigationMixin} sub-context (backed by
+ * {@link RestMixin#getHostContext()}) but renders with the host's
+ * {@link org.apache.juneau.html.annotation.HtmlDocConfig @HtmlDocConfig} page 
decoration &mdash; mixin
+ * sub-contexts inherit the host's class-level {@code @HtmlDocConfig} (and 
other class-level config), so the
+ * navigation page matches the host's other endpoints.
  *
  * <p>
  * Children are attached to this resource using the {@link Rest#children() 
@Rest(children)} annotation.
@@ -60,24 +58,9 @@ import jakarta.servlet.http.*;
  *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/RestAnnotatedClassBasics";>@Rest-Annotated
 Class Basics</a>
  * </ul>
  */
-@Rest
+@Rest(mixins=NavigationMixin.class)
 public abstract class BasicRestResourceGroup extends BasicRestResource {
 
-       /**
-        * [GET /] - Get child resources.
-        *
-        * <p>
-        * Returns a bean that lists and allows navigation to child resources. 
Default implementation
-        * delegates to {@link ChildResourceDescriptions#of(RestRequest)}; 
subclasses may override.
-        *
-        * @param req The HTTP request.
-        * @return The bean containing links to the child resources.
-        */
-       @RestGet(path="/", summary="Navigation page")
-       public ChildResourceDescriptions getChildren(RestRequest req) {
-               return ChildResourceDescriptions.of(req);
-       }
-
        /**
         * Returns the {@link RestChildren} registry backing this group 
resource.
         *
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestServletGroup.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestServletGroup.java
index 43a3efb338..0662e99adc 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestServletGroup.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestServletGroup.java
@@ -18,7 +18,6 @@ package org.apache.juneau.rest.servlet;
 
 import org.apache.juneau.rest.*;
 import org.apache.juneau.rest.annotation.*;
-import org.apache.juneau.rest.beans.*;
 import org.apache.juneau.rest.config.*;
 import org.apache.juneau.rest.ops.*;
 
@@ -35,14 +34,13 @@ import jakarta.servlet.*;
  * for details.
  *
  * <p>
- * Adds the group-navigation endpoint ({@code GET /}) as a concrete method on 
top of the residual op-mixins
- * inherited from {@link BasicRestServlet}. The navigation page is rendered as 
a method of the host resource
- * (rather than the {@link NavigationMixin} sub-context mixin) so it inherits 
the host's
- * {@link org.apache.juneau.html.annotation.HtmlDocConfig @HtmlDocConfig} page 
decoration &mdash; a mixin
- * sub-context does not inherit the host's class-level {@code @HtmlDocConfig}, 
so a navigation op living on a
- * mixin renders without the host's navlinks/aside/footer/theme.  {@link 
NavigationMixin} (backed by
- * {@link RestMixin#getHostContext()}) is the mixin flavor for hosts where 
that page decoration is not
- * required (e.g. JSON-only APIs).
+ * Adds the group-navigation endpoint ({@code GET /}) by composing {@link 
NavigationMixin} via
+ * {@link Rest#mixins() @Rest(mixins=...)} on top of the residual op-mixins 
inherited from
+ * {@link BasicRestServlet}. The navigation op lives on the {@code 
NavigationMixin} sub-context (backed by
+ * {@link RestMixin#getHostContext()}) but renders with the host's
+ * {@link org.apache.juneau.html.annotation.HtmlDocConfig @HtmlDocConfig} page 
decoration &mdash; mixin
+ * sub-contexts inherit the host's class-level {@code @HtmlDocConfig} (and 
other class-level config), so the
+ * navigation page matches the host's other endpoints.
  *
  * <p>
  * Children are attached to this resource using the {@link Rest#children() 
@Rest(children)} annotation.
@@ -61,25 +59,10 @@ import jakarta.servlet.*;
  *
  * @serial exclude
  */
-@Rest
+@Rest(mixins=NavigationMixin.class)
 public abstract class BasicRestServletGroup extends BasicRestServlet {
        private static final long serialVersionUID = 1L;
 
-       /**
-        * [GET /] - Get child resources.
-        *
-        * <p>
-        * Returns a bean that lists and allows navigation to child resources. 
Default implementation
-        * delegates to {@link ChildResourceDescriptions#of(RestRequest)}; 
subclasses may override.
-        *
-        * @param req The HTTP request.
-        * @return The bean containing links to the child resources.
-        */
-       @RestGet(path="/", summary="Navigation page")
-       public ChildResourceDescriptions getChildren(RestRequest req) {
-               return ChildResourceDescriptions.of(req);
-       }
-
        /**
         * Returns the {@link RestChildren} registry backing this group 
resource.
         *
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicOps_ParentChain_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicOps_ParentChain_Test.java
index 5cf042e5d0..75b3893ef5 100644
--- 
a/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicOps_ParentChain_Test.java
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicOps_ParentChain_Test.java
@@ -22,6 +22,7 @@ import org.apache.juneau.*;
 import org.apache.juneau.commons.inject.*;
 import org.apache.juneau.rest.*;
 import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.config.*;
 import org.apache.juneau.rest.guard.*;
 import org.apache.juneau.rest.mock.classic.*;
 import org.apache.juneau.rest.servlet.*;
@@ -57,7 +58,7 @@ class BasicOps_ParentChain_Test extends TestBase {
        @Rest(
                mixins={EchoMixin.class, AdminMixin.class, 
RouteIndexMixin.class},
                debug=@Debug("always"))
-       public static class A extends RestServlet {
+       public static class A extends RestServlet implements 
BasicUniversalConfig {
                private static final long serialVersionUID = 1L;
                @RestGet(path="/items", summary="List items") public String 
items() { return "items"; }
 
@@ -94,7 +95,7 @@ class BasicOps_ParentChain_Test extends TestBase {
        }
 
        @Test void a05_routeIndexOptionsResolves() throws Exception {
-               var body = 
c.get("/options").run().assertStatus(200).getContent().asString();
+               var body = 
c.get("/options").accept("application/json").run().assertStatus(200).getContent().asString();
                // Host's own endpoint must appear.
                assertTrue(body.contains("/items"), "host /items must be in the 
index; body: " + body);
                // Ops endpoints must NOT appear (all carry 
@OpSwagger(ignore=true)).
@@ -108,7 +109,7 @@ class BasicOps_ParentChain_Test extends TestBase {
                        "admin endpoints must be excluded from index; body: " + 
body);
                assertFalse(body.contains("/admin/ratelimit"),
                        "admin endpoints must be excluded from index; body: " + 
body);
-               assertFalse(body.contains("\"path\": \"/options\""),
+               assertFalse(body.contains("/options"),
                        "route-index endpoint must not echo itself; body: " + 
body);
        }
 
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/ops/MixinHtmlDocInheritance_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/MixinHtmlDocInheritance_Test.java
new file mode 100644
index 0000000000..a824bdd7ee
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/MixinHtmlDocInheritance_Test.java
@@ -0,0 +1,166 @@
+/*
+ * 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.ops;
+
+import org.apache.juneau.*;
+import org.apache.juneau.html.annotation.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.config.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validates that a mixin op inherits the host resource's class-level {@link 
HtmlDocConfig @HtmlDocConfig}
+ * page decoration (TODO-148).
+ *
+ * <p>
+ * A mixin's {@code @RestOp} methods bind to a per-mixin {@link RestContext} 
sub-context whose
+ * {@code getResourceClass()} is the mixin class, not the host. Without 
inheritance the mixin's HTML page
+ * renders with default (empty) decoration even when the host declares 
class-level {@code @HtmlDocConfig}.
+ * These tests cover host-only config, mixin-only config (still wins), and 
host+mixin-both precedence /
+ * list-merge semantics, plus the {@link StatsMixin#getStats} per-method 
regression guard.
+ *
+ * @since 9.5.0
+ */
+@SuppressWarnings({"serial"})
+class MixinHtmlDocInheritance_Test extends TestBase {
+
+       // A page-less mixin op: relies entirely on the host's class-level 
@HtmlDocConfig.
+       @Rest
+       public static class A01_HostOnlyMixin extends RestMixin {
+               @RestGet(path="/m")
+               public Object m() { return "OK"; }
+       }
+
+       // Host declares class-level decoration; mixin op declares none.
+       @Rest(mixins=A01_HostOnlyMixin.class)
+       @HtmlDocConfig(navlinks={"home: servlet:/"}, aside={"hostAside"})
+       public static class A01_HostOnly extends BasicRestServlet implements 
BasicJsonHtmlConfig {}
+
+       @Test void a01_hostOnlyConfigInheritedByMixinOp() throws Exception {
+               var c = MockRestClient.build(A01_HostOnly.class);
+               var r = 
c.get("/m").accept("text/html").run().assertStatus(200).getContent().asString();
+               assertContains(r, "<aside>hostAside</aside>");
+               assertContains(r, "home");
+       }
+
+       // Mixin op carries its own per-method config; host declares none.
+       @Rest
+       public static class A02_MixinOnlyMixin extends RestMixin {
+               @RestGet(path="/m")
+               @HtmlDocConfig(aside={"mixinAside"})
+               public Object m() { return "OK"; }
+       }
+
+       @Rest(mixins=A02_MixinOnlyMixin.class)
+       public static class A02_MixinOnly extends BasicRestServlet implements 
BasicJsonHtmlConfig {}
+
+       @Test void a02_mixinOnlyConfigStillWins() throws Exception {
+               var c = MockRestClient.build(A02_MixinOnly.class);
+               var r = 
c.get("/m").accept("text/html").run().assertStatus(200).getContent().asString();
+               assertContains(r, "<aside>mixinAside</aside>");
+       }
+
+       // Both host and mixin-op declare config: covers method-level INHERIT 
merge and method-level override.
+       @Rest
+       public static class A03_BothMixin extends RestMixin {
+               // Method INHERIT pulls in the host's class-level aside, then 
appends the mixin's own value.
+               @RestGet(path="/merge")
+               @HtmlDocConfig(aside={"INHERIT","mixinAside"})
+               public Object merge() { return "OK"; }
+
+               // Method declares aside without INHERIT: overrides the host's 
class-level aside entirely.
+               @RestGet(path="/override")
+               @HtmlDocConfig(aside={"overrideAside"})
+               public Object override() { return "OK"; }
+       }
+
+       @Rest(mixins=A03_BothMixin.class)
+       @HtmlDocConfig(aside={"hostAside"})
+       public static class A03_Both extends BasicRestServlet implements 
BasicJsonHtmlConfig {}
+
+       @Test void a03_hostAndMixinMergeSemantics() throws Exception {
+               var c = MockRestClient.build(A03_Both.class);
+               var merge = 
c.get("/merge").accept("text/html").run().assertStatus(200).getContent().asString();
+               assertContains(merge, "<aside>hostAside mixinAside</aside>");
+               var override = 
c.get("/override").accept("text/html").run().assertStatus(200).getContent().asString();
+               assertContains(override, "<aside>overrideAside</aside>");
+       }
+
+       // Mixin-class-level config overrides host-class config (precedence: 
mixin-class > host-class).
+       @Rest
+       @HtmlDocConfig(aside={"mixinClassAside"})
+       public static class A04_MixinClassMixin extends RestMixin {
+               @RestGet(path="/m")
+               public Object m() { return "OK"; }
+       }
+
+       @Rest(mixins=A04_MixinClassMixin.class)
+       @HtmlDocConfig(aside={"hostAside"})
+       public static class A04_MixinClassWins extends BasicRestServlet 
implements BasicJsonHtmlConfig {}
+
+       @Test void a04_mixinClassOverridesHostClass() throws Exception {
+               var c = MockRestClient.build(A04_MixinClassWins.class);
+               var r = 
c.get("/m").accept("text/html").run().assertStatus(200).getContent().asString();
+               assertContains(r, "<aside>mixinClassAside</aside>");
+       }
+
+       // Opt-out: @Rest(noInherit) naming the host config annotation type 
suppresses inheritance.
+       @Rest(noInherit={"HtmlDocConfig"})
+       public static class A05_NoInheritMixin extends RestMixin {
+               @RestGet(path="/m")
+               public Object m() { return "OK"; }
+       }
+
+       @Rest(mixins=A05_NoInheritMixin.class)
+       @HtmlDocConfig(aside={"hostAside"})
+       public static class A05_NoInherit extends BasicRestServlet implements 
BasicJsonHtmlConfig {}
+
+       @Test void a05_noInheritSuppressesHostConfig() throws Exception {
+               var c = MockRestClient.build(A05_NoInherit.class);
+               var r = 
c.get("/m").accept("text/html").run().assertStatus(200).getContent().asString();
+               assertNotContains(r, "hostAside");
+       }
+
+       // Regression guard: StatsMixin's per-method @HtmlDocConfig (navlinks 
back/json, aside=NONE) still renders,
+       // and is not clobbered by the host's class-level decoration.
+       @Rest(mixins=StatsMixin.class)
+       @HtmlDocConfig(navlinks={"home: servlet:/"}, aside={"hostAside"})
+       public static class A06_StatsHost extends BasicRestServlet implements 
BasicJsonHtmlConfig {}
+
+       @Test void a06_statsMixinPerMethodConfigStillRenders() throws Exception 
{
+               var c = MockRestClient.build(A06_StatsHost.class);
+               var r = 
c.get("/stats").accept("text/html").run().assertStatus(200).getContent().asString();
+               // StatsMixin.getStats declares its own navlinks (back/json) at 
rank=10 and aside=NONE, so the host's
+               // aside content must not appear on the /stats page.
+               assertContains(r, "back");
+               assertNotContains(r, "hostAside");
+       }
+
+       private static void assertContains(String s, String needle) {
+               if (!s.contains(needle))
+                       throw new AssertionError("Expected to contain '" + 
needle + "' but did not. Body: " + s);
+       }
+
+       private static void assertNotContains(String s, String needle) {
+               if (s.contains(needle))
+                       throw new AssertionError("Expected NOT to contain '" + 
needle + "' but did. Body excerpt: "
+                               + s.substring(Math.max(0, s.indexOf(needle) - 
50), Math.min(s.length(), s.indexOf(needle) + 100)));
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/ops/RouteIndexMixin_AsMixin_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/RouteIndexMixin_AsMixin_Test.java
index 550ca2015d..a904758846 100644
--- 
a/juneau-utest/src/test/java/org/apache/juneau/rest/ops/RouteIndexMixin_AsMixin_Test.java
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/RouteIndexMixin_AsMixin_Test.java
@@ -21,6 +21,7 @@ import java.util.*;
 import org.apache.juneau.*;
 import org.apache.juneau.json.*;
 import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.config.*;
 import org.apache.juneau.rest.mock.classic.*;
 import org.apache.juneau.rest.servlet.*;
 import org.junit.jupiter.api.*;
@@ -53,7 +54,7 @@ import org.junit.jupiter.api.*;
 class RouteIndexMixin_AsMixin_Test extends TestBase {
 
        @Rest(mixins=RouteIndexMixin.class)
-       public static class A extends RestServlet {
+       public static class A extends RestServlet implements 
BasicUniversalConfig {
                private static final long serialVersionUID = 1L;
 
                @RestGet(path="/items", summary="List items") public String 
items() { return "items"; }
@@ -73,6 +74,7 @@ class RouteIndexMixin_AsMixin_Test extends TestBase {
 
        @Test void a01_optionsReturnsJsonList() throws Exception {
                var body = ca.get("/options")
+                       .accept("application/json")
                        .run()
                        .assertStatus(200)
                        
.assertHeader("Content-Type").isContains("application/json")
@@ -88,7 +90,7 @@ class RouteIndexMixin_AsMixin_Test extends TestBase {
        }
 
        @Test void a03_listsAllVisibleHostEndpoints() throws Exception {
-               var entries = 
parseEntries(ca.get("/options").run().assertStatus(200).getContent().asString());
+               var entries = 
parseEntries(ca.get("/options").accept("application/json").run().assertStatus(200).getContent().asString());
                var paths = pathsOf(entries);
                Assertions.assertTrue(paths.contains("/items"), "GET /items 
should appear");
                Assertions.assertTrue(paths.contains("/items/{id}"), "GET 
/items/{id} should appear");
@@ -96,28 +98,28 @@ class RouteIndexMixin_AsMixin_Test extends TestBase {
        }
 
        @Test void a04_excludesItself() throws Exception {
-               var entries = 
parseEntries(ca.get("/options").run().assertStatus(200).getContent().asString());
+               var entries = 
parseEntries(ca.get("/options").accept("application/json").run().assertStatus(200).getContent().asString());
                var paths = pathsOf(entries);
                Assertions.assertFalse(paths.contains("/options"),
                        "Route index must not echo itself; got: " + paths);
        }
 
        @Test void a05_excludesOpSwaggerIgnoreEndpoints() throws Exception {
-               var entries = 
parseEntries(ca.get("/options").run().assertStatus(200).getContent().asString());
+               var entries = 
parseEntries(ca.get("/options").accept("application/json").run().assertStatus(200).getContent().asString());
                var paths = pathsOf(entries);
                Assertions.assertFalse(paths.contains("/internal"),
                        "@OpSwagger(ignore=true) endpoints must be excluded; 
got: " + paths);
        }
 
        @Test void a06_summaryFieldPopulated() throws Exception {
-               var entries = 
parseEntries(ca.get("/options").run().assertStatus(200).getContent().asString());
+               var entries = 
parseEntries(ca.get("/options").accept("application/json").run().assertStatus(200).getContent().asString());
                var byPath = byPath(entries);
                Assertions.assertEquals("List items", byPath.get("/items 
GET").get("summary"));
                Assertions.assertEquals("Get item", byPath.get("/items/{id} 
GET").get("summary"));
        }
 
        @Test void a07_methodsMapsToRequestMethod() throws Exception {
-               var entries = 
parseEntries(ca.get("/options").run().assertStatus(200).getContent().asString());
+               var entries = 
parseEntries(ca.get("/options").accept("application/json").run().assertStatus(200).getContent().asString());
                var byPath = byPath(entries);
                Assertions.assertEquals(List.of("GET"), byPath.get("/items 
GET").get("methods"));
                Assertions.assertEquals(List.of("POST"), byPath.get("/items 
POST").get("methods"));
@@ -125,7 +127,7 @@ class RouteIndexMixin_AsMixin_Test extends TestBase {
        }
 
        @Test void a08_deprecatedFlagPropagates() throws Exception {
-               var entries = 
parseEntries(ca.get("/options").run().assertStatus(200).getContent().asString());
+               var entries = 
parseEntries(ca.get("/options").accept("application/json").run().assertStatus(200).getContent().asString());
                var byPath = byPath(entries);
                var legacy = byPath.get("/legacy GET");
                Assertions.assertNotNull(legacy, "legacy entry must be 
present");
@@ -135,7 +137,7 @@ class RouteIndexMixin_AsMixin_Test extends TestBase {
        }
 
        @Test void a09_orderedByPathAscending() throws Exception {
-               var entries = 
parseEntries(ca.get("/options").run().assertStatus(200).getContent().asString());
+               var entries = 
parseEntries(ca.get("/options").accept("application/json").run().assertStatus(200).getContent().asString());
                var paths = pathsOf(entries);
                var sorted = new ArrayList<>(paths);
                Collections.sort(sorted);
@@ -151,7 +153,7 @@ class RouteIndexMixin_AsMixin_Test extends TestBase {
 
        @Deprecated
        @Rest(mixins=RouteIndexMixin.class)
-       public static class C extends RestServlet {
+       public static class C extends RestServlet implements 
BasicUniversalConfig {
                private static final long serialVersionUID = 1L;
                @RestGet(path="/c-item", summary="C item",
                        description={"line one", "line two"})
@@ -161,7 +163,7 @@ class RouteIndexMixin_AsMixin_Test extends TestBase {
        private static final MockRestClient cc = 
MockRestClient.buildLax(C.class);
 
        @Test void c01_classLevelDeprecatedPropagates() throws Exception {
-               var entries = 
parseEntries(cc.get("/options").run().assertStatus(200).getContent().asString());
+               var entries = 
parseEntries(cc.get("/options").accept("application/json").run().assertStatus(200).getContent().asString());
                var byPath = byPath(entries);
                var entry = byPath.get("/c-item GET");
                Assertions.assertNotNull(entry, "class-level @Deprecated entry 
must be present");
@@ -170,7 +172,7 @@ class RouteIndexMixin_AsMixin_Test extends TestBase {
        }
 
        @Test void c02_multilineDescriptionJoined() throws Exception {
-               var entries = 
parseEntries(cc.get("/options").run().assertStatus(200).getContent().asString());
+               var entries = 
parseEntries(cc.get("/options").accept("application/json").run().assertStatus(200).getContent().asString());
                var byPath = byPath(entries);
                var entry = byPath.get("/c-item GET");
                Assertions.assertEquals("line one line two", 
entry.get("description"),
@@ -183,7 +185,7 @@ class RouteIndexMixin_AsMixin_Test extends TestBase {
        // 
-----------------------------------------------------------------------------------------
 
        @Rest(mixins=RouteIndexMixin.class)
-       public static class B extends RestServlet {
+       public static class B extends RestServlet implements 
BasicUniversalConfig {
                private static final long serialVersionUID = 1L;
                @RestGet(path="/only") public String only() { return "only"; }
        }
@@ -191,7 +193,7 @@ class RouteIndexMixin_AsMixin_Test extends TestBase {
        private static final MockRestClient cb = 
MockRestClient.buildLax(B.class);
 
        @Test void b01_minimalHostListsOneEntry() throws Exception {
-               var entries = 
parseEntries(cb.get("/options").run().assertStatus(200).getContent().asString());
+               var entries = 
parseEntries(cb.get("/options").accept("application/json").run().assertStatus(200).getContent().asString());
                var paths = pathsOf(entries);
                Assertions.assertEquals(List.of("/only"), paths,
                        "Only the host's own /only endpoint should appear; got: 
" + paths);
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/ops/RouteIndexMixin_ContentNegotiation_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/RouteIndexMixin_ContentNegotiation_Test.java
new file mode 100644
index 0000000000..14df23e10a
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/RouteIndexMixin_ContentNegotiation_Test.java
@@ -0,0 +1,101 @@
+/*
+ * 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.ops;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.config.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validates that the {@link RouteIndexMixin} route-index op is 
<b>content-negotiated</b> &mdash; it returns a
+ * {@link org.apache.juneau.rest.beans.RouteDescriptions} POJO rather than 
writing a fixed format, so the host's
+ * configured serializers render HTML / JSON / XML based on the request {@code 
Accept} header.
+ *
+ * <p>
+ * Mirrors the {@code NavigationMixin.getChildren(...)} pattern (see {@link 
NavigationMixin_AsMixin_Test}): the host
+ * provides serializers (here via {@link BasicUniversalConfig}), an {@code 
Accept: text/html} request yields a
+ * browsable page with the route {@linkplain 
org.apache.juneau.rest.beans.RouteDescription#getPath() path} rendered as
+ * a clickable link, while {@code application/json} / {@code text/xml} clients 
receive the same entries in their
+ * requested format.
+ *
+ * @since 9.5.0
+ */
+class RouteIndexMixin_ContentNegotiation_Test extends TestBase {
+
+       @Rest(mixins=RouteIndexMixin.class)
+       public static class A extends RestServlet implements 
BasicUniversalConfig {
+               private static final long serialVersionUID = 1L;
+               @RestGet(path="/items", summary="List items") public String 
items() { return "items"; }
+               @RestPost(path="/items", summary="Create item") public String 
create() { return "created"; }
+       }
+
+       private static final MockRestClient a = 
MockRestClient.buildLax(A.class);
+
+       @Test void a01_htmlYieldsBrowsablePageWithLinks() throws Exception {
+               var body = a.get("/options")
+                       .accept("text/html")
+                       .run()
+                       .assertStatus(200)
+                       .assertHeader("Content-Type").isContains("text/html")
+                       .getContent().asString();
+               // Rendered as an HTML table whose path cells are clickable 
links.
+               assertTrue(body.contains("<table"), "HTML output should render 
a table; got: " + body);
+               assertTrue(body.contains("href=") && body.contains("/items"),
+                       "HTML output should render the route path as a 
clickable link; got: " + body);
+       }
+
+       @Test void a02_jsonYieldsStrictJson() throws Exception {
+               var body = a.get("/options")
+                       .accept("application/json")
+                       .run()
+                       .assertStatus(200)
+                       
.assertHeader("Content-Type").isContains("application/json")
+                       .getContent().asString();
+               // Must be strict, parseable JSON (not Json5) preserving the 
path/methods/summary/description/deprecated shape.
+               var parsed = JsonParser.DEFAULT.parse(body, List.class);
+               assertFalse(parsed.isEmpty(), "JSON route index should not be 
empty");
+               assertTrue(body.contains("\"path\""), "JSON output should carry 
the 'path' property; got: " + body);
+               assertTrue(body.contains("/items"), "JSON output should list 
/items; got: " + body);
+       }
+
+       @Test void a03_xmlYieldsXml() throws Exception {
+               var body = a.get("/options")
+                       .accept("text/xml")
+                       .run()
+                       .assertStatus(200)
+                       .assertHeader("Content-Type").isContains("xml")
+                       .getContent().asString();
+               assertTrue(body.contains("/items"), "XML output should list 
/items; got: " + body);
+       }
+
+       @Test void a04_sameEntriesAcrossFormats() throws Exception {
+               // All three negotiated formats must surface the same routes 
(both /items ops here).
+               var json = 
a.get("/options").accept("application/json").run().assertStatus(200).getContent().asString();
+               var html = 
a.get("/options").accept("text/html").run().assertStatus(200).getContent().asString();
+               var xml = 
a.get("/options").accept("text/xml").run().assertStatus(200).getContent().asString();
+               for (var format : List.of(json, html, xml))
+                       assertTrue(format.contains("/items"), "Every negotiated 
format should list /items; got: " + format);
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/ops/RouteIndexMixin_SvlPathOverride_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/RouteIndexMixin_SvlPathOverride_Test.java
index 339af9d1d9..df19b5957d 100644
--- 
a/juneau-utest/src/test/java/org/apache/juneau/rest/ops/RouteIndexMixin_SvlPathOverride_Test.java
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/RouteIndexMixin_SvlPathOverride_Test.java
@@ -18,6 +18,7 @@ package org.apache.juneau.rest.ops;
 
 import org.apache.juneau.*;
 import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.config.*;
 import org.apache.juneau.rest.mock.classic.*;
 import org.apache.juneau.rest.servlet.*;
 import org.junit.jupiter.api.*;
@@ -41,7 +42,7 @@ import org.junit.jupiter.api.*;
 class RouteIndexMixin_SvlPathOverride_Test extends TestBase {
 
        @Rest(mixins=RouteIndexMixin.class)
-       public static class A01_OverridePath extends RestServlet {
+       public static class A01_OverridePath extends RestServlet implements 
BasicUniversalConfig {
                private static final long serialVersionUID = 1L;
                @RestGet(path="/items") public String items() { return "items"; 
}
        }
@@ -56,6 +57,7 @@ class RouteIndexMixin_SvlPathOverride_Test extends TestBase {
                        c.get("/options").run().assertStatus(404);
 
                        c.get("/all-routes")
+                               .accept("application/json")
                                .run()
                                .assertStatus(200)
                                
.assertHeader("Content-Type").isContains("application/json")
@@ -67,7 +69,7 @@ class RouteIndexMixin_SvlPathOverride_Test extends TestBase {
        }
 
        @Rest(mixins=RouteIndexMixin.class)
-       public static class A02_RoutesAliasMigration extends RestServlet {
+       public static class A02_RoutesAliasMigration extends RestServlet 
implements BasicUniversalConfig {
                private static final long serialVersionUID = 1L;
                @RestGet(path="/items") public String items() { return "items"; 
}
        }
@@ -82,6 +84,7 @@ class RouteIndexMixin_SvlPathOverride_Test extends TestBase {
                        c.get("/options").run().assertStatus(404);
 
                        c.get("/routes")
+                               .accept("application/json")
                                .run()
                                .assertStatus(200)
                                
.assertContent().asString().isContains("/items");
@@ -92,7 +95,7 @@ class RouteIndexMixin_SvlPathOverride_Test extends TestBase {
        }
 
        @Rest(mixins=RouteIndexMixin.class)
-       public static class A03_BareToken extends RestServlet {
+       public static class A03_BareToken extends RestServlet implements 
BasicUniversalConfig {
                private static final long serialVersionUID = 1L;
                @RestGet(path="/items") public String items() { return "items"; 
}
        }
@@ -103,7 +106,7 @@ class RouteIndexMixin_SvlPathOverride_Test extends TestBase 
{
                System.setProperty(key, "xxx");
                try {
                        var c = MockRestClient.buildLax(A03_BareToken.class);
-                       
c.get("/xxx").run().assertStatus(200).assertContent().asString().isContains("/items");
+                       
c.get("/xxx").accept("application/json").run().assertStatus(200).assertContent().asString().isContains("/items");
                } finally {
                        if (prev == null) System.clearProperty(key);
                        else System.setProperty(key, prev);
@@ -111,7 +114,7 @@ class RouteIndexMixin_SvlPathOverride_Test extends TestBase 
{
        }
 
        @Rest(mixins=RouteIndexMixin.class)
-       public static class A04_LeadingSlash extends RestServlet {
+       public static class A04_LeadingSlash extends RestServlet implements 
BasicUniversalConfig {
                private static final long serialVersionUID = 1L;
                @RestGet(path="/items") public String items() { return "items"; 
}
        }
@@ -122,7 +125,7 @@ class RouteIndexMixin_SvlPathOverride_Test extends TestBase 
{
                System.setProperty(key, "/xxx");
                try {
                        var c = MockRestClient.buildLax(A04_LeadingSlash.class);
-                       
c.get("/xxx").run().assertStatus(200).assertContent().asString().isContains("/items");
+                       
c.get("/xxx").accept("application/json").run().assertStatus(200).assertContent().asString().isContains("/items");
                } finally {
                        if (prev == null) System.clearProperty(key);
                        else System.setProperty(key, prev);
@@ -130,7 +133,7 @@ class RouteIndexMixin_SvlPathOverride_Test extends TestBase 
{
        }
 
        @Rest(mixins=RouteIndexMixin.class)
-       public static class A05_TrailingSlash extends RestServlet {
+       public static class A05_TrailingSlash extends RestServlet implements 
BasicUniversalConfig {
                private static final long serialVersionUID = 1L;
                @RestGet(path="/items") public String items() { return "items"; 
}
        }
@@ -141,7 +144,7 @@ class RouteIndexMixin_SvlPathOverride_Test extends TestBase 
{
                System.setProperty(key, "xxx/");
                try {
                        var c = 
MockRestClient.buildLax(A05_TrailingSlash.class);
-                       
c.get("/xxx").run().assertStatus(200).assertContent().asString().isContains("/items");
+                       
c.get("/xxx").accept("application/json").run().assertStatus(200).assertContent().asString().isContains("/items");
                } finally {
                        if (prev == null) System.clearProperty(key);
                        else System.setProperty(key, prev);
@@ -149,7 +152,7 @@ class RouteIndexMixin_SvlPathOverride_Test extends TestBase 
{
        }
 
        @Rest(mixins=RouteIndexMixin.class)
-       public static class A06_BothSlashes extends RestServlet {
+       public static class A06_BothSlashes extends RestServlet implements 
BasicUniversalConfig {
                private static final long serialVersionUID = 1L;
                @RestGet(path="/items") public String items() { return "items"; 
}
        }
@@ -160,7 +163,7 @@ class RouteIndexMixin_SvlPathOverride_Test extends TestBase 
{
                System.setProperty(key, "/xxx/");
                try {
                        var c = MockRestClient.buildLax(A06_BothSlashes.class);
-                       
c.get("/xxx").run().assertStatus(200).assertContent().asString().isContains("/items");
+                       
c.get("/xxx").accept("application/json").run().assertStatus(200).assertContent().asString().isContains("/items");
                } finally {
                        if (prev == null) System.clearProperty(key);
                        else System.setProperty(key, prev);
@@ -168,7 +171,7 @@ class RouteIndexMixin_SvlPathOverride_Test extends TestBase 
{
        }
 
        @Rest(mixins=RouteIndexMixin.class)
-       public static class A07_MultiSegment extends RestServlet {
+       public static class A07_MultiSegment extends RestServlet implements 
BasicUniversalConfig {
                private static final long serialVersionUID = 1L;
                @RestGet(path="/items") public String items() { return "items"; 
}
        }
@@ -179,7 +182,7 @@ class RouteIndexMixin_SvlPathOverride_Test extends TestBase 
{
                System.setProperty(key, "/api/v1/xxx");
                try {
                        var c = MockRestClient.buildLax(A07_MultiSegment.class);
-                       
c.get("/api/v1/xxx").run().assertStatus(200).assertContent().asString().isContains("/items");
+                       
c.get("/api/v1/xxx").accept("application/json").run().assertStatus(200).assertContent().asString().isContains("/items");
                } finally {
                        if (prev == null) System.clearProperty(key);
                        else System.setProperty(key, prev);
diff --git a/juneau-utest/test-run-history.tsv 
b/juneau-utest/test-run-history.tsv
index 7f830e4adb..719f3ff55a 100644
--- a/juneau-utest/test-run-history.tsv
+++ b/juneau-utest/test-run-history.tsv
@@ -59,3 +59,4 @@ timestamp     git_sha branch  tests_run       failures        
errors  skipped surefire_sec    wall_sec
 2026-06-01T13:18:45Z   c9d380738f06    master  126130  0       0       26      
184
 2026-06-01T14:07:35Z   40a74b4f9452    master  126135  0       0       26      
186
 2026-06-01T15:50:15Z   f167a9dccbc7    master  126165  0       0       26      
180
+2026-06-01T17:10:12Z   6f9df91cec12    master  126175  0       0       26      
181

Reply via email to