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 — 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 — 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 > mixin-class > 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 — 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 — 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 — {@code path}, {@code methods},
{@code summary}, {@code description},
+ * and {@code deprecated} — 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 — 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] — emit the route index as a JSON list.
+ * [GET /options] — 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 — mirroring the way {@code
NavigationMixin.getChildren(...)} backs the child-resource
+ * navigation page.
*
* @param req The current REST request — 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 — 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 — 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 — 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 — 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> — 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