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 ae494dbff5 feat(rest-server): ops/introspection mixin pack —
Echo/Admin/RouteIndex + DenyAllGuard (FINISHED-77)
ae494dbff5 is described below
commit ae494dbff528a10a7c806992d29d8f3fc00cb6f6
Author: James Bognar <[email protected]>
AuthorDate: Sun May 24 19:12:25 2026 -0400
feat(rest-server): ops/introspection mixin pack — Echo/Admin/RouteIndex +
DenyAllGuard (FINISHED-77)
---
.../org/apache/juneau/rest/guard/DenyAllGuard.java | 66 +++
.../apache/juneau/rest/ops/BasicAdminResource.java | 455 +++++++++++++++++++++
.../apache/juneau/rest/ops/BasicEchoResource.java | 361 ++++++++++++++++
.../juneau/rest/ops/BasicRouteIndexResource.java | 233 +++++++++++
.../org/apache/juneau/rest/ops/package-info.java | 88 ++++
.../rest/ops/BasicAdminResource_AsMixin_Test.java | 337 +++++++++++++++
.../rest/ops/BasicEchoResource_AsMixin_Test.java | 326 +++++++++++++++
.../BasicEchoResource_JettyMicroservice_Test.java | 133 ++++++
.../ops/BasicEchoResource_Springboot_Test.java | 120 ++++++
.../rest/ops/BasicOps_OpenApiHidden_Test.java | 100 +++++
.../juneau/rest/ops/BasicOps_ParentChain_Test.java | 122 ++++++
.../ops/BasicRouteIndexResource_AsMixin_Test.java | 218 ++++++++++
...n.md => FINISHED-77-mixin-ops-introspection.md} | 95 ++++-
todo/TODO-69-authn-guards-jwt-apikey.md | 1 +
todo/TODO.md | 6 +-
15 files changed, 2657 insertions(+), 4 deletions(-)
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/guard/DenyAllGuard.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/guard/DenyAllGuard.java
new file mode 100644
index 0000000000..af6a112a63
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/guard/DenyAllGuard.java
@@ -0,0 +1,66 @@
+/*
+ * 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.guard;
+
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+
+/**
+ * Deny-all {@link RestGuard} — rejects every request with {@code 403
Forbidden}.
+ *
+ * <p>
+ * Used as the secure-by-default placeholder on operations or resources that
require an
+ * authentication / authorization chain the importer must explicitly opt into.
Without the
+ * importer's override, every request is denied; once a user-supplied
+ * {@link org.apache.juneau.commons.inject.Bean @Bean} {@link RestGuardList}
is registered on the
+ * resource, the framework's bean-store override seam <b>replaces</b> the
entire
+ * annotation-derived guard list (including this deny-all) with the
user-supplied chain —
+ * see the {@code RestOpContext.guards} memoizer for the full lookup contract.
+ *
+ * <h5 class='figure'>Composition example:</h5>
+ *
+ * <p class='bjava'>
+ * <jc>// Default-deny on a resource until the importer wires up auth.</jc>
+ * <ja>@Rest</ja>(path=<js>"/admin"</js>,
guards=DenyAllGuard.<jk>class</jk>)
+ * <jk>public class</jk> AdminResource <jk>extends</jk> RestServlet {
+ *
+ * <jc>// Importer opt-in: register a guard chain that allows
authorized callers through.</jc>
+ * <ja>@Bean</ja>(name=<js>"guards"</js>)
+ * <jk>public</jk> RestGuardList guards(BeanStore <jv>bs</jv>) {
+ * <jk>return</jk>
RestGuardList.<jsm>create</jsm>(<jv>bs</jv>)
+ * .append(<jk>new</jk> MyAuthGuard())
+ * .build();
+ * }
+ * }
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jc'>{@link RestGuard}
+ * <li class='jc'>{@link RestGuardList}
+ * <li class='ja'>{@link Rest#guards()}
+ * <li class='ja'>{@link RestOp#guards()}
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public class DenyAllGuard extends RestGuard {
+
+ @Override /* Overridden from RestGuard */
+ public boolean isRequestAllowed(RestRequest req) {
+ return false;
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicAdminResource.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicAdminResource.java
new file mode 100644
index 0000000000..08f9e72073
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicAdminResource.java
@@ -0,0 +1,455 @@
+/*
+ * 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 java.io.*;
+import java.lang.management.*;
+import java.util.*;
+import java.util.function.*;
+
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.http.annotation.*;
+import org.apache.juneau.http.response.*;
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.guard.*;
+
+/**
+ * Mixin that serves operational-introspection endpoints under {@code
/admin/*}: thread dump,
+ * heap statistics, configurable cache-flush hooks, and rate-limit-bucket
inspection.
+ *
+ * <p>
+ * Sibling of {@link BasicEchoResource} ({@code /echo/*} / {@code
/debug/echo/*}) and
+ * {@link BasicRouteIndexResource} ({@code /options} / {@code /routes}). All
three classes live in
+ * the {@code org.apache.juneau.rest.ops} ops/introspection mixin pack.
+ *
+ * <h5 class='section'>Default-deny security posture:</h5>
+ *
+ * <p>
+ * The mixin is annotated with {@link Rest#guards()
@Rest(guards=DenyAllGuard.class)}, so every
+ * admin path returns {@code 403 Forbidden} until the importer registers a
+ * {@link org.apache.juneau.commons.inject.Bean @Bean} {@link RestGuardList}
factory on the host.
+ * The framework's bean-store override seam <b>replaces</b> the entire
annotation-derived guard
+ * list (including this deny-all) with the user-supplied chain — pair
the mixin with whatever
+ * authentication / authorization story your service uses (bearer-token guard,
API-key guard,
+ * Spring Security adapter, etc.).
+ *
+ * <p>
+ * <b>Why deny-all rather than a non-existent role-name placeholder?</b> The
role-guard approach
+ * (declaring a role that no real principal has) has clean semantics but
harder ergonomics: the
+ * importer overrides by setting the principal's roles to include the
placeholder, which couples
+ * the host's auth strategy to a framework-internal role name. Replacing a
{@link RestGuardList}
+ * via {@code @Bean} keeps the override surface narrow — one factory
method on the host
+ * — and matches the way every other Juneau op-context bean is wired.
+ *
+ * <h5 class='figure'>Composition example:</h5>
+ *
+ * <p class='bjava'>
+ * <ja>@Rest</ja>(path=<js>"/api"</js>,
mixins=BasicAdminResource.<jk>class</jk>)
+ * <jk>public class</jk> ApiResource <jk>extends</jk> RestServlet {
+ *
+ * <jc>// Required: register an auth guard chain to unlock the
admin paths.</jc>
+ * <ja>@Bean</ja>(name=<js>"guards"</js>)
+ * <jk>public</jk> RestGuardList guards(BeanStore <jv>bs</jv>) {
+ * <jk>return</jk>
RestGuardList.<jsm>create</jsm>(<jv>bs</jv>)
+ * .append(<jk>new</jk> MyBearerTokenGuard())
+ * .build();
+ * }
+ *
+ * <ja>@Bean</ja> BasicAdminResource admin() {
+ * <jk>return</jk> BasicAdminResource.<jsm>create</jsm>()
+ * .cacheFlush(<js>"primary"</js>, () ->
primaryCache.invalidateAll())
+ * .build();
+ * }
+ * }
+ * </p>
+ *
+ * <h5 class='section'>Endpoints:</h5>
+ *
+ * <ul class='spaced-list'>
+ * <li><b>{@code GET /admin/threads}</b> — emits a JSON list of
currently-live threads. The
+ * default thread filter excludes framework noise (JVM internals,
servlet container, Spring
+ * Boot infrastructure); override via {@link
Builder#threadNamePrefixExclude(String...)}.
+ * <li><b>{@code GET /admin/heap}</b> — emits a JSON map with {@code
Runtime} heap stats
+ * ({@code total}, {@code free}, {@code max}, {@code used}) plus
+ * {@link MemoryMXBean#getNonHeapMemoryUsage() non-heap} memory
usage. No heap-dump file
+ * generation in v1 (security risk).
+ * <li><b>{@code POST /admin/cache/flush}</b> — runs all registered
cache-flush hooks, or
+ * just a comma-separated {@code names} subset when supplied as a
query parameter. Returns a
+ * JSON map of the hooks that were invoked. Hooks are registered
name-keyed via
+ * {@link Builder#cacheFlush(String,Runnable)}; users that want
async semantics own the
+ * threading model.
+ * <li><b>{@code GET /admin/ratelimit}</b> — emits a JSON map keyed
by bean name listing the
+ * registered {@link RateLimitGuard} configuration. Returns {@code
404 Not Found} when no
+ * {@code RateLimitGuard} bean is registered on the importer's
bean store. Bucket-level
+ * inspection (per-key counters) is reserved for a follow-on once
{@code RateLimitGuard.Storage}
+ * exposes a snapshot SPI; v1 emits configuration only.
+ * </ul>
+ *
+ * <p>
+ * All four endpoints carry {@link OpSwagger#ignore() @OpSwagger(ignore=true)}
so the admin surface
+ * stays out of any generated Swagger / OpenAPI spec.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jc'>{@link BasicEchoResource}
+ * <li class='jc'>{@link BasicRouteIndexResource}
+ * <li class='jc'>{@link DenyAllGuard}
+ * <li class='jc'>{@link RestGuardList}
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/RestServerComposition">REST Server
— Composition (mixins, paths)</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+// @formatter:off
+@Rest(
+
paths={"/admin/threads","/admin/heap","/admin/cache/flush","/admin/ratelimit"},
+ guards=DenyAllGuard.class
+)
+public class BasicAdminResource {
+
+ /**
+ * Default thread-name-prefix exclude list: filter out JVM internals,
servlet container, and
+ * Spring Boot infrastructure threads.
+ */
+ public static final List<String> DEFAULT_THREAD_NAME_PREFIX_EXCLUDE =
List.of(
+ "Reference Handler", "Finalizer", "Signal Dispatcher",
"Common-Cleaner",
+ "Notification Thread", "Attach Listener",
+ "jetty-", "qtp",
+ "spring-",
+ "GC ", "G1 ");
+
+ /**
+ * Creates a new builder.
+ *
+ * @return A new builder.
+ */
+ public static Builder create() {
+ return new Builder();
+ }
+
+ private final Map<String,Runnable> cacheFlushHooks;
+ private final List<String> threadNamePrefixExclude;
+
+ /** No-arg constructor — uses the default thread filter and no
cache-flush hooks. */
+ public BasicAdminResource() {
+ this(create());
+ }
+
+ /**
+ * Builder constructor.
+ *
+ * @param builder The builder.
+ */
+ protected BasicAdminResource(Builder builder) {
+ cacheFlushHooks = Collections.unmodifiableMap(new
LinkedHashMap<>(builder.cacheFlushHooks));
+ threadNamePrefixExclude =
List.copyOf(builder.threadNamePrefixExclude);
+ }
+
+ /**
+ * [GET /admin/threads] — emit a JSON list of currently-live
threads.
+ *
+ * @param res The current REST response.
+ * @throws IOException If an I/O error occurs while writing the
response.
+ */
+ @RestGet(
+ path="/admin/threads",
+ summary="Thread dump",
+ description="JSON list of currently-live threads (filtered to
exclude framework noise by default).",
+ swagger=@OpSwagger(ignore=true)
+ )
+ public void getThreads(RestResponse res) throws IOException {
+ var allTraces = Thread.getAllStackTraces();
+ var out = new ArrayList<Map<String,Object>>();
+ for (var e : allTraces.entrySet()) {
+ var t = e.getKey();
+ if (isExcludedThread(t.getName()))
+ continue;
+ var entry = new LinkedHashMap<String,Object>();
+ entry.put("name", t.getName());
+ entry.put("id", t.getId());
+ entry.put("state", t.getState().toString());
+ entry.put("daemon", t.isDaemon());
+ entry.put("priority", t.getPriority());
+ var stack = new ArrayList<String>();
+ for (var f : e.getValue())
+ stack.add(f.toString());
+ entry.put("stack", stack);
+ out.add(entry);
+ }
+ writeJson(res, out);
+ }
+
+ /**
+ * [GET /admin/heap] — emit JVM heap and non-heap memory
statistics.
+ *
+ * @param res The current REST response.
+ * @throws IOException If an I/O error occurs while writing the
response.
+ */
+ @RestGet(
+ path="/admin/heap",
+ summary="Heap statistics",
+ description="JVM heap + non-heap memory statistics (Runtime +
MemoryMXBean).",
+ swagger=@OpSwagger(ignore=true)
+ )
+ public void getHeap(RestResponse res) throws IOException {
+ var rt = Runtime.getRuntime();
+ var heap = new LinkedHashMap<String,Object>();
+ heap.put("total", rt.totalMemory());
+ heap.put("free", rt.freeMemory());
+ heap.put("max", rt.maxMemory());
+ heap.put("used", rt.totalMemory() - rt.freeMemory());
+
+ var mx = ManagementFactory.getMemoryMXBean();
+ var nonHeapUsage = mx.getNonHeapMemoryUsage();
+ var nonHeap = new LinkedHashMap<String,Object>();
+ nonHeap.put("init", nonHeapUsage.getInit());
+ nonHeap.put("used", nonHeapUsage.getUsed());
+ nonHeap.put("committed", nonHeapUsage.getCommitted());
+ nonHeap.put("max", nonHeapUsage.getMax());
+
+ var out = new LinkedHashMap<String,Object>();
+ out.put("heap", heap);
+ out.put("nonHeap", nonHeap);
+ out.put("availableProcessors", rt.availableProcessors());
+ writeJson(res, out);
+ }
+
+ /**
+ * [POST /admin/cache/flush] — run all registered cache-flush
hooks (or a subset).
+ *
+ * <p>
+ * When {@code names} is supplied, only the named hooks are invoked.
Unknown names are
+ * silently ignored (404-on-unknown would leak the registered hook
set). Hook execution is
+ * synchronous; long-running hooks block the request thread —
users that want async
+ * semantics should register a hook that hands work off to an executor.
+ *
+ * @param req The current REST request — {@code names} query
parameter is read off it.
+ * @param res The current REST response.
+ * @throws IOException If an I/O error occurs while writing the
response.
+ */
+ @RestPost(
+ path="/admin/cache/flush",
+ summary="Cache flush",
+ description="Runs the registered cache-flush hooks (all by
default; ?names=foo,bar for a subset).",
+ swagger=@OpSwagger(ignore=true)
+ )
+ public void postCacheFlush(RestRequest req, RestResponse res) throws
IOException {
+ var namesParam =
req.getQueryParams().get("names").asString().orElse(null);
+ Set<String> selected = null;
+ if (namesParam != null && ! namesParam.isBlank()) {
+ selected = new LinkedHashSet<>();
+ for (var n : namesParam.split(","))
+ if (! n.isBlank())
+ selected.add(n.trim());
+ }
+ var executed = new ArrayList<String>();
+ for (var e : cacheFlushHooks.entrySet()) {
+ if (selected != null && ! selected.contains(e.getKey()))
+ continue;
+ e.getValue().run();
+ executed.add(e.getKey());
+ }
+ var out = new LinkedHashMap<String,Object>();
+ out.put("registered", new
ArrayList<>(cacheFlushHooks.keySet()));
+ out.put("executed", executed);
+ writeJson(res, out);
+ }
+
+ /**
+ * [GET /admin/ratelimit] — emit registered {@link
RateLimitGuard} bean configuration.
+ *
+ * <p>
+ * Returns {@code 404 Not Found} when no {@code RateLimitGuard} bean is
registered.
+ *
+ * @param req The current REST request — supplies the bean store
for guard lookup.
+ * @param res The current REST response.
+ * @throws IOException If an I/O error occurs while writing the
response.
+ * @throws NotFound If no {@link RateLimitGuard} bean is registered on
the importer's bean
+ * store.
+ */
+ @RestGet(
+ path="/admin/ratelimit",
+ summary="Rate-limit inspection",
+ description="JSON map of registered RateLimitGuard beans keyed
by bean name.",
+ swagger=@OpSwagger(ignore=true)
+ )
+ public void getRateLimit(RestRequest req, RestResponse res) throws
IOException {
+ var bs = req.getContext().getBeanStore();
+ var guards = collectRateLimitGuards(bs);
+ if (guards.isEmpty())
+ throw new NotFound("No RateLimitGuard bean is
registered.");
+ var entries = new LinkedHashMap<String,Object>();
+ for (var e : guards.entrySet()) {
+ var bucket = new LinkedHashMap<String,Object>();
+ bucket.put("config",
describeRateLimitGuard(e.getValue()));
+ bucket.put("buckets", List.of());
+ entries.put(e.getKey(), bucket);
+ }
+ var out = new LinkedHashMap<String,Object>();
+ out.put("guards", entries);
+ writeJson(res, out);
+ }
+
+ /**
+ * Returns the registered cache-flush hooks (test/inspection helper).
+ *
+ * @return The hooks, keyed by registration name. Never {@code null}.
+ */
+ public Map<String,Runnable> getCacheFlushHooks() {
+ return cacheFlushHooks;
+ }
+
+ /**
+ * Returns the configured thread-name-prefix exclude list
(test/inspection helper).
+ *
+ * @return The exclude list. Never {@code null}.
+ */
+ public List<String> getThreadNamePrefixExclude() {
+ return threadNamePrefixExclude;
+ }
+
+ private boolean isExcludedThread(String threadName) {
+ for (var prefix : threadNamePrefixExclude)
+ if (threadName != null && threadName.startsWith(prefix))
+ return true;
+ return false;
+ }
+
+ private static Map<String,RateLimitGuard>
collectRateLimitGuards(BeanStore bs) {
+ // Multi-bean lookup so users running per-tier guards (free /
paid / etc.) see every
+ // registered bean keyed by its bean name. Falls through to the
single-bean path when no
+ // per-tier configuration is registered.
+ var byName = bs.getBeansOfType(RateLimitGuard.class);
+ if (byName != null && ! byName.isEmpty())
+ return new LinkedHashMap<>(byName);
+ var out = new LinkedHashMap<String,RateLimitGuard>();
+ bs.getBean(RateLimitGuard.class).ifPresent(g ->
out.put("rateLimit", g));
+ return out;
+ }
+
+ private static Map<String,Object> describeRateLimitGuard(RateLimitGuard
g) {
+ var m = new LinkedHashMap<String,Object>();
+ m.put("class", g.getClass().getName());
+ return m;
+ }
+
+ private static void writeJson(RestResponse res, Object payload) throws
IOException {
+ try (var w = res.getDirectWriter("application/json")) {
+ JsonSerializer.DEFAULT_READABLE.serialize(payload, w);
+ }
+ }
+
+ /**
+ * Builder for {@link BasicAdminResource} instances.
+ */
+ public static class Builder {
+
+ private final Map<String,Runnable> cacheFlushHooks = new
LinkedHashMap<>();
+ private final List<String> threadNamePrefixExclude = new
ArrayList<>(DEFAULT_THREAD_NAME_PREFIX_EXCLUDE);
+
+ /** Constructor — package access for {@link
BasicAdminResource#create()}. */
+ protected Builder() {}
+
+ /**
+ * Registers a cache-flush hook under a name.
+ *
+ * <p>
+ * The hook is invoked synchronously when {@code POST
/admin/cache/flush} is called (with
+ * no {@code names} parameter, or with a {@code names}
parameter that includes this name).
+ *
+ * @param name The hook name. Must not be {@code null} or blank.
+ * @param hook The hook. Must not be {@code null}.
+ * @return This object.
+ */
+ public Builder cacheFlush(String name, Runnable hook) {
+ if (name == null || name.isBlank())
+ throw new IllegalArgumentException("Argument
'name' must not be null or blank");
+ if (hook == null)
+ throw new IllegalArgumentException("Argument
'hook' must not be null");
+ cacheFlushHooks.put(name, hook);
+ return this;
+ }
+
+ /**
+ * Registers multiple cache-flush hooks at once.
+ *
+ * @param hooks The hooks, keyed by registration name.
+ * @return This object.
+ */
+ public Builder cacheFlushAll(Map<String,Runnable> hooks) {
+ if (hooks != null)
+ hooks.forEach(this::cacheFlush);
+ return this;
+ }
+
+ /**
+ * Replaces the thread-name-prefix exclude list.
+ *
+ * <p>
+ * Threads whose {@link Thread#getName() name} starts with any
of the supplied prefixes are
+ * omitted from the {@code /admin/threads} output. Pass an
empty array to disable
+ * filtering entirely.
+ *
+ * @param values The thread-name prefixes to exclude. Must not
be {@code null}.
+ * @return This object.
+ */
+ public Builder threadNamePrefixExclude(String...values) {
+ threadNamePrefixExclude.clear();
+ if (values != null)
+ for (var v : values)
+ if (v != null && ! v.isEmpty())
+ threadNamePrefixExclude.add(v);
+ return this;
+ }
+
+ /**
+ * Builds a {@link BasicAdminResource} instance.
+ *
+ * @return A configured instance.
+ */
+ public BasicAdminResource build() {
+ return new BasicAdminResource(this);
+ }
+
+ /**
+ * Returns the registered cache-flush hooks (builder-time
inspection helper).
+ *
+ * @return The hooks, keyed by registration name. Never {@code
null}.
+ */
+ Map<String,Runnable> getCacheFlushHooksForTesting() {
+ return cacheFlushHooks;
+ }
+
+ /**
+ * Returns the configured thread-name-prefix exclude list
(builder-time inspection
+ * helper).
+ *
+ * @return The exclude list. Never {@code null}.
+ */
+ List<String> getThreadNamePrefixExcludeForTesting() {
+ return threadNamePrefixExclude;
+ }
+ }
+
+ /**
+ * Reserved Spring-style {@link Supplier} surface for callers that want
lazy hook resolution.
+ * Currently unused publicly; left as a sealed extension point.
+ */
+ @SuppressWarnings("unused")
+ private interface CacheFlushSupplier extends Supplier<Runnable> {}
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicEchoResource.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicEchoResource.java
new file mode 100644
index 0000000000..ecf207f0b8
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicEchoResource.java
@@ -0,0 +1,361 @@
+/*
+ * 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 java.io.*;
+import java.nio.charset.*;
+import java.util.*;
+
+import org.apache.juneau.http.annotation.*;
+import org.apache.juneau.http.response.*;
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+
+/**
+ * Mixin that serves a request-echo / round-trip introspection endpoint at
{@code /echo/*} and
+ * {@code /debug/echo/*}.
+ *
+ * <p>
+ * Sibling of {@link BasicAdminResource} ({@code /admin/*}) and {@link
BasicRouteIndexResource}
+ * ({@code /options}, {@code /routes}). All three classes live in the
+ * {@code org.apache.juneau.rest.ops} ops/introspection mixin pack.
+ *
+ * <p>
+ * Compose into a host resource via
+ * {@link Rest#mixins() @Rest(mixins=BasicEchoResource.class)}; the {@code
/echo/*} and
+ * {@code /debug/echo/*} URLs become available alongside the host's own
endpoints with no further
+ * wiring. Or extend the class directly for a standalone deployment whose
mount paths come from
+ * the inherited {@link Rest#paths() @Rest(paths)} default.
+ *
+ * <h5 class='figure'>Composition example:</h5>
+ *
+ * <p class='bjava'>
+ * <ja>@Rest</ja>(
+ * path=<js>"/api"</js>,
+ * mixins=BasicEchoResource.<jk>class</jk>,
+ * debug=<js>"conditional"</js> <jc>// gates the echo
endpoint per request</jc>
+ * )
+ * <jk>public class</jk> ApiResource <jk>extends</jk> RestServlet { }
+ *
+ * <jc>// Optional: tighten the body cap or adjust redacted headers via a
@Bean factory:</jc>
+ * <ja>@Bean</ja> BasicEchoResource echo() {
+ * <jk>return</jk> BasicEchoResource.<jsm>create</jsm>()
+ * .bodyLimit(64 * 1024L)
+ * .redactHeader(<js>"X-Internal-Trace"</js>)
+ * .build();
+ * }
+ * </p>
+ *
+ * <h5 class='section'>Debug gating:</h5>
+ *
+ * <p>
+ * The handler is gated behind the host's
+ * {@link RestContext#getDebugEnablement() DebugEnablement} chain — the
same mechanism that
+ * powers {@code @Rest(debug=...)}. When {@code Debug} resolves to {@code OFF}
for the current
+ * request, the handler returns {@code 404 Not Found} (so the existence of the
endpoint isn't
+ * disclosed). When debug is {@code ALWAYS}, or {@code CONDITIONAL} with the
{@code Debug: true}
+ * request header, the full echo payload is returned. The recommended posture
for production
+ * deployments is {@code @Rest(debug="conditional")} paired with a guard chain
so only authorized
+ * operators can flip the {@code Debug} header.
+ *
+ * <h5 class='section'>Sensitive-header redaction:</h5>
+ *
+ * <p>
+ * Token-bearing headers must never be reflected back to the caller (would
defeat any auth scheme
+ * in front of the endpoint). The default redacted list is —
case-insensitively —
+ * {@code Authorization}, {@code Cookie}, {@code Set-Cookie}, {@code
Proxy-Authorization}, and
+ * {@code X-API-Key}. Each redacted header surfaces in the echo body with the
literal value
+ * {@value #REDACTED} so the caller can see the header was present without
leaking its value.
+ * Override the default list via {@link Builder#redactedHeaders(String...)}
(replaces) or extend
+ * via {@link Builder#redactHeader(String)} (additive).
+ *
+ * <h5 class='section'>Body capture and truncation:</h5>
+ *
+ * <p>
+ * The handler reads up to {@link Builder#bodyLimit(long) bodyLimit} bytes
(default
+ * {@value #DEFAULT_BODY_LIMIT_DOC} = 1 MB) of the inbound body and emits it
as a UTF-8 string in
+ * the {@code content} field of the JSON response. When the body exceeds the
cap, the captured
+ * portion is truncated and the {@code truncated} flag is set to {@code true}
so callers can see
+ * the response is incomplete.
+ *
+ * <h5 class='section'>Response shape:</h5>
+ *
+ * <p class='bjson'>
+ * {
+ * <jok>"method"</jok>: <jov>"POST"</jov>,
+ * <jok>"path"</jok>: <jov>"/echo/foo/bar"</jov>,
+ * <jok>"queryString"</jok>: <jov>"x=1"</jov>,
+ * <jok>"pathRemainder"</jok>: <jov>"foo/bar"</jov>,
+ * <jok>"headers"</jok>: { <jok>"User-Agent"</jok>:
<jov>"curl"</jov>, <jok>"Authorization"</jok>: <jov>"[REDACTED]"</jov> },
+ * <jok>"queryParams"</jok>: { <jok>"x"</jok>: <jov>"1"</jov> },
+ * <jok>"attributes"</jok>: { },
+ * <jok>"contentLength"</jok>: <jov>5</jov>,
+ * <jok>"content"</jok>: <jov>"hello"</jov>,
+ * <jok>"truncated"</jok>: <jov>false</jov>
+ * }
+ * </p>
+ *
+ * <p>
+ * The endpoint is excluded from generated Swagger / OpenAPI specs via
+ * {@link OpSwagger#ignore() @OpSwagger(ignore=true)}.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jc'>{@link BasicAdminResource}
+ * <li class='jc'>{@link BasicRouteIndexResource}
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/RestServerComposition">REST Server
— Composition (mixins, paths)</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+// @formatter:off
+@Rest(paths={"/echo/*","/debug/echo/*"})
+public class BasicEchoResource {
+
+ /** Sentinel value emitted in place of redacted header values. */
+ public static final String REDACTED = "[REDACTED]";
+
+ /** Default body capture cap, in bytes (1 MB). */
+ public static final long DEFAULT_BODY_LIMIT = 1_048_576L;
+
+ /** Default body capture cap rendered for the class-level javadoc
— do not use programmatically. */
+ static final String DEFAULT_BODY_LIMIT_DOC = "1048576";
+
+ /** Default redacted-header set (case-insensitive lookup). */
+ public static final Set<String> DEFAULT_REDACTED_HEADERS = Set.of(
+ "Authorization", "Cookie", "Set-Cookie", "Proxy-Authorization",
"X-API-Key");
+
+ /**
+ * Creates a new builder.
+ *
+ * @return A new builder.
+ */
+ public static Builder create() {
+ return new Builder();
+ }
+
+ private final long bodyLimit;
+ private final Set<String> redactedHeadersLower;
+
+ /** No-arg constructor — uses {@link #DEFAULT_BODY_LIMIT} and
{@link #DEFAULT_REDACTED_HEADERS}. */
+ public BasicEchoResource() {
+ this(create());
+ }
+
+ /**
+ * Builder constructor.
+ *
+ * @param builder The builder.
+ */
+ protected BasicEchoResource(Builder builder) {
+ bodyLimit = builder.bodyLimit;
+ var s = new LinkedHashSet<String>();
+ for (var h : builder.redactedHeaders)
+ s.add(h.toLowerCase(Locale.ROOT));
+ redactedHeadersLower = Collections.unmodifiableSet(s);
+ }
+
+ /**
+ * [* /echo/* | /debug/echo/*] — emit an introspection echo of
the inbound request.
+ *
+ * <p>
+ * Returns {@code 404 Not Found} when {@code Debug} is not enabled for
the current request.
+ *
+ * @param req The current REST request.
+ * @param res The current REST response.
+ * @param remainder The path remainder after the mount prefix
(multi-segment, may be empty).
+ * @throws IOException If an I/O error occurs while reading the request
or writing the response.
+ * @throws NotFound When {@code Debug} resolves to {@code OFF} for the
current request.
+ */
+ @RestOp(
+ method="*",
+ path={"/echo/*","/debug/echo/*"},
+ summary="Request echo",
+ description="Round-trip introspection of the inbound request.
Debug-gated.",
+ swagger=@OpSwagger(ignore=true)
+ )
+ public void echo(RestRequest req, RestResponse res, @Path("/*") String
remainder) throws IOException {
+ var ctx = req.getContext();
+ var de = ctx.getDebugEnablement();
+ var sreq = req.getHttpServletRequest();
+ if (de == null || ! de.isDebug(ctx, sreq))
+ throw new NotFound("Echo endpoint disabled (Debug not
enabled).");
+
+ var headers = new LinkedHashMap<String,String>();
+ var names = sreq.getHeaderNames();
+ while (names != null && names.hasMoreElements()) {
+ var name = names.nextElement();
+ var key = name.toLowerCase(Locale.ROOT);
+ headers.put(name, redactedHeadersLower.contains(key) ?
REDACTED : sreq.getHeader(name));
+ }
+
+ var queryParams = new LinkedHashMap<String,String>();
+ for (var p : req.getQueryParams())
+ queryParams.put(p.getName(), p.asString().orElse(null));
+
+ var attributes = new LinkedHashMap<String,String>();
+ req.getAttributes().asMap().forEach((k, v) -> attributes.put(k,
String.valueOf(v)));
+
+ var capture = readBoundedBody(sreq.getInputStream(), bodyLimit);
+
+ var out = new LinkedHashMap<String,Object>();
+ out.put("method", req.getMethod());
+ out.put("path", sreq.getRequestURI());
+ out.put("queryString", sreq.getQueryString());
+ out.put("pathRemainder", remainder == null ? "" : remainder);
+ out.put("headers", headers);
+ out.put("queryParams", queryParams);
+ out.put("attributes", attributes);
+ out.put("contentLength", capture.bytesRead);
+ if (capture.bytesRead > 0)
+ out.put("content", new String(capture.bytes,
StandardCharsets.UTF_8));
+ out.put("truncated", capture.truncated);
+
+ try (var w = res.getDirectWriter("application/json")) {
+ JsonSerializer.DEFAULT_READABLE.serialize(out, w);
+ }
+ }
+
+ /**
+ * Returns the configured body-capture cap (test/inspection helper).
+ *
+ * @return The body capture cap, in bytes.
+ */
+ public long getBodyLimit() {
+ return bodyLimit;
+ }
+
+ /**
+ * Returns the redacted-header set as lowercased, immutable strings
(test/inspection helper).
+ *
+ * @return The redacted-header set.
+ */
+ public Set<String> getRedactedHeadersLower() {
+ return redactedHeadersLower;
+ }
+
+ private static BodyCapture readBoundedBody(InputStream in, long limit)
throws IOException {
+ if (in == null)
+ return new BodyCapture(new byte[0], 0, false);
+ var buf = new ByteArrayOutputStream();
+ var chunk = new byte[8192];
+ var truncated = false;
+ long total = 0;
+ int n;
+ while ((n = in.read(chunk)) != -1) {
+ var room = limit - total;
+ if (room <= 0) {
+ truncated = true;
+ break;
+ }
+ if (n > room) {
+ buf.write(chunk, 0, (int) room);
+ total += room;
+ truncated = true;
+ break;
+ }
+ buf.write(chunk, 0, n);
+ total += n;
+ }
+ return new BodyCapture(buf.toByteArray(), total, truncated);
+ }
+
+ private static final class BodyCapture {
+ final byte[] bytes;
+ final long bytesRead;
+ final boolean truncated;
+
+ BodyCapture(byte[] bytes, long bytesRead, boolean truncated) {
+ this.bytes = bytes;
+ this.bytesRead = bytesRead;
+ this.truncated = truncated;
+ }
+ }
+
+ /**
+ * Builder for {@link BasicEchoResource} instances.
+ */
+ public static class Builder {
+
+ private long bodyLimit = DEFAULT_BODY_LIMIT;
+ private final Set<String> redactedHeaders;
+
+ /** Constructor — package access for {@link
BasicEchoResource#create()}. */
+ protected Builder() {
+ redactedHeaders = new
LinkedHashSet<>(DEFAULT_REDACTED_HEADERS);
+ }
+
+ /**
+ * Sets the body capture cap.
+ *
+ * <p>
+ * Captured content is truncated when the inbound body exceeds
this size; the
+ * {@code truncated} flag in the response is set to {@code
true}.
+ *
+ * @param value Cap, in bytes. Must be {@code >= 0}.
+ * @return This object.
+ */
+ public Builder bodyLimit(long value) {
+ if (value < 0)
+ throw new IllegalArgumentException("bodyLimit
must be >= 0");
+ bodyLimit = value;
+ return this;
+ }
+
+ /**
+ * Replaces the redacted-header set with the supplied values.
+ *
+ * <p>
+ * Header names are matched case-insensitively. Pass an empty
array to disable redaction
+ * (not recommended outside of integration tests).
+ *
+ * @param values The header names to redact.
+ * @return This object.
+ */
+ public Builder redactedHeaders(String...values) {
+ redactedHeaders.clear();
+ if (values != null)
+ for (var v : values)
+ if (v != null && ! v.isBlank())
+ redactedHeaders.add(v);
+ return this;
+ }
+
+ /**
+ * Adds an additional header name to the redacted-header set.
+ *
+ * @param value The header name to redact (case-insensitive).
Must not be {@code null} or
+ * blank.
+ * @return This object.
+ */
+ public Builder redactHeader(String value) {
+ if (value == null || value.isBlank())
+ throw new IllegalArgumentException("Argument
'value' must not be null or blank");
+ redactedHeaders.add(value);
+ return this;
+ }
+
+ /**
+ * Builds a {@link BasicEchoResource} instance.
+ *
+ * @return A configured instance.
+ */
+ public BasicEchoResource build() {
+ return new BasicEchoResource(this);
+ }
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicRouteIndexResource.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicRouteIndexResource.java
new file mode 100644
index 0000000000..96d886c11b
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicRouteIndexResource.java
@@ -0,0 +1,233 @@
+/*
+ * 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 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.*;
+
+/**
+ * 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} and {@code
/routes}.
+ *
+ * <p>
+ * Sibling of {@link BasicEchoResource} ({@code /echo/*} / {@code
/debug/echo/*}) and
+ * {@link BasicAdminResource} ({@code /admin/*}). All three classes live in the
+ * {@code org.apache.juneau.rest.ops} ops/introspection mixin pack.
+ *
+ * <h5 class='figure'>Composition example:</h5>
+ *
+ * <p class='bjava'>
+ * <ja>@Rest</ja>(path=<js>"/api"</js>,
mixins=BasicRouteIndexResource.<jk>class</jk>)
+ * <jk>public class</jk> ApiResource <jk>extends</jk> RestServlet {
+ * <ja>@RestGet</ja>(path=<js>"/items"</js>, summary=<js>"List
items"</js>) <jk>public</jk> List<Item> items() { ... }
+ * }
+ * </p>
+ *
+ * <h5 class='section'>Output:</h5>
+ *
+ * <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:
+ *
+ * <p class='bjson'>
+ * [
+ * {
+ * <jok>"path"</jok>: <jov>"/items"</jov>,
+ * <jok>"methods"</jok>: [<jov>"GET"</jov>],
+ * <jok>"summary"</jok>: <jov>"List items"</jov>,
+ * <jok>"description"</jok>: <jov>""</jov>,
+ * <jok>"deprecated"</jok>: <jov>false</jov>
+ * }
+ * ]
+ * </p>
+ *
+ * <p>
+ * <b>Excluded entries:</b>
+ * <ul class='spaced-list'>
+ * <li>The route-index endpoint itself (it shouldn't echo its own listing).
+ * <li>Any operation marked {@link OpSwagger#ignore()
@OpSwagger(ignore=true)} — consistent
+ * with how those operations are excluded from the OpenAPI spec by
+ * {@code BasicSwaggerProviderSession}. Convention endpoints
(favicon, robots, version, etc.),
+ * static-files mixin handlers, and the sibling ops-pack endpoints
all carry that annotation
+ * and are therefore omitted from the index, matching the audience
separation: api-docs is
+ * for documented public API; route-index is for the same surface
but in machine-readable
+ * form.
+ * <li>Lifecycle / filter beans — only methods with a {@link
RestOp}-group annotation
+ * (GET / POST / PUT / DELETE / PATCH / OPTIONS / RestOp) are
listed; {@link RestStartCall}
+ * / {@link RestEndCall} / converters / matchers are not.
+ * </ul>
+ *
+ * <p>
+ * The handler itself carries {@link OpSwagger#ignore()
@OpSwagger(ignore=true)} so the
+ * route-index endpoint is excluded from the OpenAPI spec.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jc'>{@link BasicEchoResource}
+ * <li class='jc'>{@link BasicAdminResource}
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/RestServerComposition">REST Server
— Composition (mixins, paths)</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+// @formatter:off
+@Rest(paths={"/options","/routes"})
+public class BasicRouteIndexResource {
+
+ private static final List<Class<? extends Annotation>>
REST_OP_ANNOTATIONS = List.of(
+ RestGet.class, RestPost.class, RestPut.class, RestDelete.class,
+ RestPatch.class, RestOptions.class, RestOp.class);
+
+ /** No-arg constructor — route-index has no configurable state. */
+ public BasicRouteIndexResource() {}
+
+ /**
+ * [GET /options | /routes] — emit the route index as a JSON list.
+ *
+ * @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.
+ */
+ @RestGet(
+ path={"/options","/routes"},
+ summary="Route index",
+ description="JSON 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);
+ }
+ }
+
+ private static RestContext resolveHostContext(RestContext c) {
+ var ctx = c;
+ while (ctx.isMixinContext() && ctx.getParentContext() != null)
+ ctx = ctx.getParentContext();
+ return ctx;
+ }
+
+ /**
+ * Collects the route-index entries from the supplied host {@link
RestContext} and any of its
+ * registered mixin sub-contexts (test/inspection helper).
+ *
+ * @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) {
+ var seen = new HashSet<Method>();
+ var entries = new ArrayList<Map<String,Object>>();
+ for (var oc : hostCtx.getRestOperations().getOpContexts())
+ addEntry(entries, seen, oc);
+ for (var mixinCtx : hostCtx.getMixinContexts().values())
+ for (var oc :
mixinCtx.getRestOperations().getOpContexts())
+ addEntry(entries, seen, oc);
+ entries.sort(BasicRouteIndexResource::compareByPathThenMethod);
+ return entries;
+ }
+
+ private static void addEntry(List<Map<String,Object>> entries,
Set<Method> seen, RestOpContext oc) {
+ var m = oc.getJavaMethod();
+ if (m == null || ! seen.add(m))
+ return;
+ if (isHiddenFromIndex(m))
+ 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);
+ }
+
+ @SuppressWarnings("java:S3776") // Cognitive-complexity: linear walk
over a small annotation list; splitting hurts JIT.
+ private static boolean isHiddenFromIndex(Method m) {
+ for (var aClass : REST_OP_ANNOTATIONS) {
+ var a = m.getAnnotation(aClass);
+ if (a == null)
+ continue;
+ try {
+ var sw = aClass.getMethod("swagger").invoke(a);
+ if (sw instanceof OpSwagger os && os.ignore())
+ return true;
+ } catch (ReflectiveOperationException e) {
+ // Annotation chain shape mismatch — treat as
not-hidden so we don't silently drop
+ // real entries on a future refactor.
+ return false;
+ }
+ }
+ return false;
+ }
+
+ private static boolean isSelfHandler(Method m) {
+ return m.getDeclaringClass() == BasicRouteIndexResource.class;
+ }
+
+ @SuppressWarnings("java:S3776") // Same as isHiddenFromIndex — short
loop, single concern.
+ private static String readSummary(Method m) {
+ for (var aClass : REST_OP_ANNOTATIONS) {
+ var a = m.getAnnotation(aClass);
+ if (a == null)
+ continue;
+ try {
+ var s = (String)
aClass.getMethod("summary").invoke(a);
+ if (s != null && ! s.isEmpty())
+ return s;
+ } catch (ReflectiveOperationException e) {
+ // fall through
+ }
+ }
+ return "";
+ }
+
+ @SuppressWarnings("java:S3776") // Same as isHiddenFromIndex — short
loop, single concern.
+ private static String readDescription(Method m) {
+ for (var aClass : REST_OP_ANNOTATIONS) {
+ var a = m.getAnnotation(aClass);
+ if (a == null)
+ continue;
+ try {
+ var d =
aClass.getMethod("description").invoke(a);
+ if (d instanceof String[] arr && arr.length > 0)
+ return String.join(" ", arr);
+ if (d instanceof String s && ! s.isEmpty())
+ return s;
+ } catch (ReflectiveOperationException e) {
+ // fall through
+ }
+ }
+ 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")));
+ if (c != 0)
+ return c;
+ return
String.valueOf(a.get("methods")).compareTo(String.valueOf(b.get("methods")));
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/package-info.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/package-info.java
new file mode 100644
index 0000000000..f28f2a722b
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/package-info.java
@@ -0,0 +1,88 @@
+/*
+ * 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.
+ */
+/**
+ * Ops / introspection mixin pack — composable {@code @Rest(mixins=...)}
resources that ship the
+ * operational surface (request echo, JVM admin, route index) every
long-running Juneau service
+ * eventually grows.
+ *
+ * <p>
+ * Three sibling mixins compose into the host {@code @Rest}-annotated
resource. Each mixin owns
+ * its default mount paths, ships secure-by-default (debug-gated for echo,
deny-all-guard for
+ * admin), and is independently mountable; the three together drop in as a
pack via
+ * {@code @Rest(mixins={BasicEchoResource.class, BasicAdminResource.class,
BasicRouteIndexResource.class})}.
+ * </p>
+ *
+ * <ul class='javatreec'>
+ * <li class='jc'>{@link org.apache.juneau.rest.ops.BasicEchoResource} —
+ * {@code /echo/*} and {@code /debug/echo/*} request echo, gated
behind the host's
+ * {@link org.apache.juneau.rest.debug.DebugEnablement
DebugEnablement}; sensitive headers
+ * ({@code Authorization}, {@code Cookie}, etc.) are redacted by
default.
+ * <li class='jc'>{@link org.apache.juneau.rest.ops.BasicAdminResource} —
+ * {@code /admin/threads}, {@code /admin/heap}, {@code
/admin/cache/flush} (POST), and
+ * {@code /admin/ratelimit}; default-deny via
+ * {@link org.apache.juneau.rest.guard.DenyAllGuard} until the
importer registers an
+ * {@code @Bean RestGuardList} factory.
+ * <li class='jc'>{@link
org.apache.juneau.rest.ops.BasicRouteIndexResource} —
+ * {@code /options} and {@code /routes} returning a JSON list of
every
+ * {@code @RestOp}-annotated method on the host (plus mixins),
excluding
+ * {@code @OpSwagger(ignore=true)} ops.
+ * </ul>
+ *
+ * <h5 class='section'>Composition example:</h5>
+ *
+ * <p class='bjava'>
+ * <ja>@Rest</ja>(
+ * path=<js>"/api"</js>,
+ * mixins={
+ * BasicEchoResource.<jk>class</jk>,
+ * BasicAdminResource.<jk>class</jk>,
+ * BasicRouteIndexResource.<jk>class</jk>
+ * },
+ * debug=<js>"conditional"</js> <jc>// gates
BasicEchoResource per-request</jc>
+ * )
+ * <jk>public class</jk> ApiResource <jk>extends</jk> RestServlet {
+ *
+ * <jc>// Required: register an auth guard chain; replaces the
deny-all default.</jc>
+ * <ja>@Bean</ja>(name=<js>"guards"</js>)
+ * <jk>public</jk> RestGuardList guards(BeanStore <jv>bs</jv>) {
+ * <jk>return</jk>
RestGuardList.<jsm>create</jsm>(<jv>bs</jv>)
+ * .append(<jk>new</jk> MyAuthGuard())
+ * .build();
+ * }
+ *
+ * <ja>@Bean</ja> BasicAdminResource admin() {
+ * <jk>return</jk> BasicAdminResource.<jsm>create</jsm>()
+ * .cacheFlush(<js>"primary"</js>, () ->
primaryCache.invalidateAll())
+ * .build();
+ * }
+ * }
+ * </p>
+ *
+ * <p>
+ * All endpoints carry
+ * {@link org.apache.juneau.rest.annotation.OpSwagger#ignore()
@OpSwagger(ignore=true)} —
+ * ops endpoints are not API-meaningful and are excluded from any generated
Swagger / OpenAPI
+ * spec.
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/RestServerComposition">REST Server
— Composition (mixins, paths)</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+package org.apache.juneau.rest.ops;
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicAdminResource_AsMixin_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicAdminResource_AsMixin_Test.java
new file mode 100644
index 0000000000..c1a360b0a2
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicAdminResource_AsMixin_Test.java
@@ -0,0 +1,337 @@
+/*
+ * 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 java.util.*;
+import java.util.concurrent.atomic.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.guard.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validates {@link BasicAdminResource} mounted as a mixin via {@code
@Rest(mixins=...)} on a
+ * vanilla {@link RestServlet}.
+ *
+ * <p>
+ * Cases:
+ * <ul>
+ * <li>Default-deny: {@link DenyAllGuard} returns {@code 403 Forbidden} on
every admin path until
+ * the host registers a {@code @Bean RestGuardList}.
+ * <li>{@code GET /admin/threads} returns a JSON list with at least the
JUnit test thread.
+ * <li>{@code GET /admin/heap} returns a JSON map with {@code heap.total /
free / max / used}.
+ * <li>{@code POST /admin/cache/flush} runs all registered hooks (no
{@code names}) or just the
+ * named subset.
+ * <li>{@code GET /admin/ratelimit} returns {@code 404} when no {@link
RateLimitGuard} bean is
+ * registered, and a populated map when one is.
+ * <li>Builder-time validation rejects null/blank cache-flush names and
null hooks.
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+class BasicAdminResource_AsMixin_Test extends TestBase {
+
+ //
-----------------------------------------------------------------------------------------
+ // Default-deny posture (no host-supplied @Bean RestGuardList).
+ //
-----------------------------------------------------------------------------------------
+
+ @Rest(mixins=BasicAdminResource.class)
+ public static class A extends RestServlet {
+ private static final long serialVersionUID = 1L;
+ @RestGet(path="/items") public String items() { return "items";
}
+ }
+
+ private static final MockRestClient ca =
MockRestClient.buildLax(A.class);
+
+ @Test void a01_threadsDeniedByDefault() throws Exception {
+ ca.get("/admin/threads").run().assertStatus(403);
+ }
+
+ @Test void a02_heapDeniedByDefault() throws Exception {
+ ca.get("/admin/heap").run().assertStatus(403);
+ }
+
+ @Test void a03_cacheFlushDeniedByDefault() throws Exception {
+ ca.post("/admin/cache/flush", "").run().assertStatus(403);
+ }
+
+ @Test void a04_rateLimitDeniedByDefault() throws Exception {
+ ca.get("/admin/ratelimit").run().assertStatus(403);
+ }
+
+ @Test void a05_hostEndpointStillReachable() throws Exception {
+
ca.get("/items").run().assertStatus(200).assertContent().asString().isContains("items");
+ }
+
+ //
-----------------------------------------------------------------------------------------
+ // Allow-all RestGuardList override (replaces DenyAllGuard).
+ //
-----------------------------------------------------------------------------------------
+
+ /** Allow-all guard chain — replaces the mixin's annotation-derived
deny-all. */
+ @Rest(mixins=BasicAdminResource.class)
+ public static class B extends RestServlet {
+ private static final long serialVersionUID = 1L;
+ @RestGet(path="/items") public String items() { return "items";
}
+
+ @Bean public RestGuardList guards(BeanStore bs) {
+ return RestGuardList.create(bs).build();
+ }
+
+ @Bean public BasicAdminResource admin() {
+ return BasicAdminResource.create()
+ .cacheFlush("primary", FLUSH_PRIMARY)
+ .cacheFlush("secondary", FLUSH_SECONDARY)
+ .threadNamePrefixExclude() // disable
filtering to make /admin/threads deterministic
+ .build();
+ }
+ }
+
+ static final AtomicInteger PRIMARY_INVOCATIONS = new AtomicInteger();
+ static final AtomicInteger SECONDARY_INVOCATIONS = new AtomicInteger();
+ static final Runnable FLUSH_PRIMARY =
PRIMARY_INVOCATIONS::incrementAndGet;
+ static final Runnable FLUSH_SECONDARY =
SECONDARY_INVOCATIONS::incrementAndGet;
+
+ private static final MockRestClient cb =
MockRestClient.buildLax(B.class);
+
+ @BeforeEach void resetCounters() {
+ PRIMARY_INVOCATIONS.set(0);
+ SECONDARY_INVOCATIONS.set(0);
+ }
+
+ @Test void b01_threadsServesJsonList() throws Exception {
+ var body = cb.get("/admin/threads")
+ .run()
+ .assertStatus(200)
+
.assertHeader("Content-Type").isContains("application/json")
+ .getContent().asString();
+ var parsed = JsonParser.DEFAULT.parse(body, List.class);
+ Assertions.assertFalse(parsed.isEmpty(), "thread list should
not be empty");
+ var first = (Map<?,?>) parsed.get(0);
+ Assertions.assertNotNull(first.get("name"));
+ Assertions.assertNotNull(first.get("state"));
+ Assertions.assertNotNull(first.get("stack"));
+ }
+
+ @Test void b02_heapServesJsonMap() throws Exception {
+ var body = cb.get("/admin/heap")
+ .run()
+ .assertStatus(200)
+
.assertHeader("Content-Type").isContains("application/json")
+ .getContent().asString();
+ var parsed = JsonParser.DEFAULT.parse(body, Map.class);
+ var heap = (Map<?,?>) parsed.get("heap");
+ Assertions.assertNotNull(heap.get("total"));
+ Assertions.assertNotNull(heap.get("free"));
+ Assertions.assertNotNull(heap.get("max"));
+ Assertions.assertNotNull(heap.get("used"));
+ var nonHeap = (Map<?,?>) parsed.get("nonHeap");
+ Assertions.assertNotNull(nonHeap.get("used"));
+ }
+
+ @Test void b03_cacheFlushAllRunsEveryHook() throws Exception {
+ cb.post("/admin/cache/flush", "")
+ .run()
+ .assertStatus(200)
+ .assertContent().asString().isContains("\"executed\"");
+ Assertions.assertEquals(1, PRIMARY_INVOCATIONS.get(), "primary
should run once");
+ Assertions.assertEquals(1, SECONDARY_INVOCATIONS.get(),
"secondary should run once");
+ }
+
+ @Test void b04_cacheFlushNamesRunsSubsetOnly() throws Exception {
+ cb.post("/admin/cache/flush?names=primary", "")
+ .run()
+ .assertStatus(200);
+ Assertions.assertEquals(1, PRIMARY_INVOCATIONS.get(), "primary
should run");
+ Assertions.assertEquals(0, SECONDARY_INVOCATIONS.get(),
"secondary should NOT run");
+ }
+
+ @Test void b05_cacheFlushNamesUnknownIsSilentlyIgnored() throws
Exception {
+ var body =
cb.post("/admin/cache/flush?names=primary,unknown,secondary", "")
+ .run()
+ .assertStatus(200)
+ .getContent().asString();
+ Assertions.assertTrue(body.contains("primary"));
+ Assertions.assertTrue(body.contains("secondary"));
+ Assertions.assertEquals(1, PRIMARY_INVOCATIONS.get());
+ Assertions.assertEquals(1, SECONDARY_INVOCATIONS.get());
+ }
+
+ @Test void b06_rateLimit404WhenNoGuardRegistered() throws Exception {
+ cb.get("/admin/ratelimit").run().assertStatus(404);
+ }
+
+ @Test void b07_hostEndpointStillReachable() throws Exception {
+
cb.get("/items").run().assertStatus(200).assertContent().asString().isContains("items");
+ }
+
+ //
-----------------------------------------------------------------------------------------
+ // Allow-all guard list + a registered RateLimitGuard bean.
+ //
-----------------------------------------------------------------------------------------
+
+ @Rest(mixins=BasicAdminResource.class)
+ public static class C extends RestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @Bean public RestGuardList guards(BeanStore bs) {
+ return RestGuardList.create(bs).build();
+ }
+
+ @Bean public RateLimitGuard rateLimit() {
+ return RateLimitGuard.create().build();
+ }
+ }
+
+ private static final MockRestClient cc =
MockRestClient.buildLax(C.class);
+
+ @Test void c01_rateLimitListsRegisteredGuard() throws Exception {
+ var body = cc.get("/admin/ratelimit")
+ .run()
+ .assertStatus(200)
+ .getContent().asString();
+ var parsed = JsonParser.DEFAULT.parse(body, Map.class);
+ var guardsMap = (Map<?,?>) parsed.get("guards");
+ Assertions.assertEquals(1, guardsMap.size(), "expected one
entry; got: " + guardsMap.keySet());
+ var first = (Map<?,?>) guardsMap.values().iterator().next();
+ Assertions.assertNotNull(first.get("config"));
+ }
+
+ //
-----------------------------------------------------------------------------------------
+ // Builder-time validation.
+ //
-----------------------------------------------------------------------------------------
+
+ @Test void d01_builderRejectsBlankCacheFlushName() {
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> BasicAdminResource.create().cacheFlush("", () ->
{}));
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> BasicAdminResource.create().cacheFlush(null, ()
-> {}));
+ }
+
+ @Test void d02_builderRejectsNullHook() {
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> BasicAdminResource.create().cacheFlush("foo",
null));
+ }
+
+ @Test void d03_cacheFlushAllRoundTrips() {
+ Map<String,Runnable> hooks = new LinkedHashMap<>();
+ var r1 = (Runnable) () -> {};
+ var r2 = (Runnable) () -> {};
+ hooks.put("a", r1);
+ hooks.put("b", r2);
+ var admin =
BasicAdminResource.create().cacheFlushAll(hooks).build();
+ Assertions.assertEquals(2, admin.getCacheFlushHooks().size());
+ Assertions.assertSame(r1, admin.getCacheFlushHooks().get("a"));
+ Assertions.assertSame(r2, admin.getCacheFlushHooks().get("b"));
+ }
+
+ @Test void d04_threadNameExcludeReplaceList() {
+ var admin =
BasicAdminResource.create().threadNamePrefixExclude("foo-", "bar-").build();
+ Assertions.assertEquals(List.of("foo-", "bar-"),
admin.getThreadNamePrefixExclude());
+ }
+
+ @Test void d05_defaultExcludeListContainsKnownNoise() {
+
Assertions.assertTrue(BasicAdminResource.DEFAULT_THREAD_NAME_PREFIX_EXCLUDE.contains("Reference
Handler"));
+
Assertions.assertTrue(BasicAdminResource.DEFAULT_THREAD_NAME_PREFIX_EXCLUDE.contains("jetty-"));
+ }
+
+ @Test void d06_noArgConstructorMatchesEmptyHooks() {
+ var r = new BasicAdminResource();
+ Assertions.assertTrue(r.getCacheFlushHooks().isEmpty());
+
Assertions.assertEquals(BasicAdminResource.DEFAULT_THREAD_NAME_PREFIX_EXCLUDE,
+ r.getThreadNamePrefixExclude());
+ }
+
+ @Test void d07_denyAllGuardRejects() throws Exception {
+ var g = new DenyAllGuard();
+ Assertions.assertFalse(g.isRequestAllowed((RestRequest) null),
+ "DenyAllGuard must always reject");
+ }
+
+ @Test void d08_cacheFlushAllNullIsNoOp() {
+ var admin =
BasicAdminResource.create().cacheFlushAll(null).build();
+ Assertions.assertTrue(admin.getCacheFlushHooks().isEmpty());
+ }
+
+ @Test void d09_threadNamePrefixExcludeNullClearsList() {
+ var admin =
BasicAdminResource.create().threadNamePrefixExclude((String[]) null).build();
+
Assertions.assertTrue(admin.getThreadNamePrefixExclude().isEmpty(),
+ "null varargs should clear the list");
+ }
+
+ @Test void d10_threadNamePrefixExcludeFiltersNullAndEmpty() {
+ var admin =
BasicAdminResource.create().threadNamePrefixExclude("good-", null, "",
"also-").build();
+ Assertions.assertEquals(List.of("good-", "also-"),
admin.getThreadNamePrefixExclude());
+ }
+
+ //
-----------------------------------------------------------------------------------------
+ // Thread filtering hits an actual matching prefix (covers the
exclusion branch in
+ // isExcludedThread when the configured filter list catches a real
thread).
+ //
-----------------------------------------------------------------------------------------
+
+ @Rest(mixins=BasicAdminResource.class)
+ public static class F extends RestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @Bean public RestGuardList guards(BeanStore bs) { return
RestGuardList.create(bs).build(); }
+
+ // Prefix that will match the JUnit launcher / test thread on
every supported JVM.
+ @Bean public BasicAdminResource admin() {
+ return BasicAdminResource.create()
+ .threadNamePrefixExclude("ForkJoinPool",
"main", "junit-")
+ .build();
+ }
+ }
+
+ private static final MockRestClient cf =
MockRestClient.buildLax(F.class);
+
+ @Test void f01_threadFilterDropsMatchedPrefixes() throws Exception {
+ var body = cf.get("/admin/threads")
+ .run()
+ .assertStatus(200)
+ .getContent().asString();
+ // We don't assert on specific thread names — just confirm the
endpoint serves and
+ // that the JSON is a valid list. The branch coverage on
isExcludedThread is what we
+ // actually care about (the prefix-match true branch).
+ Assertions.assertTrue(body.startsWith("["), "expected JSON
array; body: " + body);
+ }
+
+ //
-----------------------------------------------------------------------------------------
+ // cacheFlush with names= containing blank entries (covers the
n.isBlank() filter branch).
+ //
-----------------------------------------------------------------------------------------
+
+ @Test void g01_cacheFlushNamesBlankSegmentsIgnored() throws Exception {
+ cb.post("/admin/cache/flush?names=,primary,,", "")
+ .run()
+ .assertStatus(200);
+ Assertions.assertEquals(1, PRIMARY_INVOCATIONS.get());
+ Assertions.assertEquals(0, SECONDARY_INVOCATIONS.get());
+ }
+
+ @Test void g02_cacheFlushNamesParamBlankAllRunAll() throws Exception {
+ cb.post("/admin/cache/flush?names=", "")
+ .run()
+ .assertStatus(200);
+ // names=<blank> → namesParam.isBlank() → fall-through to "no
filter, run everything"
+ Assertions.assertEquals(1, PRIMARY_INVOCATIONS.get());
+ Assertions.assertEquals(1, SECONDARY_INVOCATIONS.get());
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_AsMixin_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_AsMixin_Test.java
new file mode 100644
index 0000000000..13dd94c7fc
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_AsMixin_Test.java
@@ -0,0 +1,326 @@
+/*
+ * 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 java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validates {@link BasicEchoResource} mounted as a mixin via {@code
@Rest(mixins=...)} on a vanilla
+ * {@link RestServlet}.
+ *
+ * <p>
+ * Cases covered:
+ * <ul>
+ * <li>Default deny — no {@code @Rest(debug)} on the host returns
{@code 404} from
+ * {@code /echo/*} so the endpoint's existence isn't disclosed.
+ * <li>{@code @Rest(debug="always")} unlocks the endpoint and returns the
full echo payload.
+ * <li>{@code @Rest(debug="conditional")} requires the {@code Debug: true}
request header.
+ * <li>Sensitive headers ({@code Authorization}, {@code Cookie}) are
redacted by default.
+ * <li>Importer's {@code @Bean BasicEchoResource} factory drives the body
cap and redact list.
+ * <li>Body capture truncates correctly when the inbound body exceeds the
configured cap.
+ * <li>Path remainder, query string, and query params are surfaced.
+ * <li>The handler dispatches on {@code @RestOp(method="*")} — POST
and PUT both work.
+ * <li>The host's own endpoints remain reachable.
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+class BasicEchoResource_AsMixin_Test extends TestBase {
+
+ /** Default-host mounting the mixin without {@code @Rest(debug)}. */
+ @Rest(mixins=BasicEchoResource.class)
+ public static class A extends RestServlet {
+ private static final long serialVersionUID = 1L;
+ @RestGet(path="/items") public String items() { return "items";
}
+ }
+
+ private static final MockRestClient ca =
MockRestClient.buildLax(A.class);
+
+ @Test void a01_echoReturns404WhenDebugDisabled() throws Exception {
+ ca.get("/echo/anything").run().assertStatus(404);
+ }
+
+ @Test void a02_debugEchoReturns404WhenDebugDisabled() throws Exception {
+ ca.get("/debug/echo/something").run().assertStatus(404);
+ }
+
+ @Test void a03_hostEndpointStillReachable() throws Exception {
+
ca.get("/items").run().assertStatus(200).assertContent().asString().isContains("items");
+ }
+
+ /** Host with debug always-on so the echo endpoint serves. */
+ @Rest(mixins=BasicEchoResource.class, debug="always")
+ public static class B extends RestServlet {
+ private static final long serialVersionUID = 1L;
+ @RestGet(path="/items") public String items() { return "items";
}
+ }
+
+ private static final MockRestClient cb =
MockRestClient.buildLax(B.class);
+
+ @Test void b01_echoServesFullPayload() throws Exception {
+ var body = cb.get("/echo/foo/bar?x=1&y=hello")
+ .run()
+ .assertStatus(200)
+
.assertHeader("Content-Type").isContains("application/json")
+ .getContent().asString();
+ var parsed = JsonParser.DEFAULT.parse(body, Map.class);
+ Assertions.assertEquals("GET", parsed.get("method"));
+ Assertions.assertTrue(((String)
parsed.get("path")).endsWith("/echo/foo/bar"),
+ "path should end with /echo/foo/bar; got: " +
parsed.get("path"));
+ Assertions.assertEquals("x=1&y=hello",
parsed.get("queryString"));
+ Assertions.assertEquals("foo/bar", parsed.get("pathRemainder"));
+ Assertions.assertEquals(Boolean.FALSE, parsed.get("truncated"));
+ var qp = (Map<?,?>) parsed.get("queryParams");
+ Assertions.assertEquals("1", qp.get("x"));
+ Assertions.assertEquals("hello", qp.get("y"));
+ }
+
+ @Test void b02_debugEchoAlsoServesAtAlternateMount() throws Exception {
+ cb.get("/debug/echo/abc")
+ .run()
+ .assertStatus(200)
+
.assertContent().asString().isContains("\"pathRemainder\": \"abc\"");
+ }
+
+ @Test void b03_authorizationHeaderRedacted() throws Exception {
+ var body = cb.get("/echo/")
+ .header("Authorization", "Bearer secret-token")
+ .run()
+ .assertStatus(200)
+ .getContent().asString();
+ Assertions.assertFalse(body.contains("Bearer secret-token"),
+ "Authorization value must NEVER be reflected back; body
was: " + body);
+ Assertions.assertTrue(body.contains(BasicEchoResource.REDACTED),
+ "Redaction sentinel should appear; body was: " + body);
+ }
+
+ @Test void b04_cookieHeaderRedacted() throws Exception {
+ var body = cb.get("/echo/")
+ .header("Cookie", "JSESSIONID=DEADBEEF")
+ .run()
+ .assertStatus(200)
+ .getContent().asString();
+ Assertions.assertFalse(body.contains("DEADBEEF"),
+ "Cookie value must NEVER be reflected back; body was: "
+ body);
+ }
+
+ @Test void b05_postEchoesBody() throws Exception {
+ var body = cb.post("/echo/posting", "hello-world")
+ .run()
+ .assertStatus(200)
+ .getContent().asString();
+ var parsed = JsonParser.DEFAULT.parse(body, Map.class);
+ Assertions.assertEquals("POST", parsed.get("method"));
+ Assertions.assertEquals("hello-world", parsed.get("content"));
+ Assertions.assertEquals(11L, ((Number)
parsed.get("contentLength")).longValue());
+ Assertions.assertEquals(Boolean.FALSE, parsed.get("truncated"));
+ }
+
+ @Test void b06_putAlsoDispatchesViaWildcardMethod() throws Exception {
+ cb.put("/echo/x", "payload")
+ .run()
+ .assertStatus(200)
+ .assertContent().asString().isContains("\"method\":
\"PUT\"");
+ }
+
+ /** Host with conditional debug — requires {@code Debug: true} request
header to unlock echo. */
+ @Rest(mixins=BasicEchoResource.class, debug="conditional")
+ public static class C extends RestServlet {
+ private static final long serialVersionUID = 1L;
+ }
+
+ private static final MockRestClient cc =
MockRestClient.buildLax(C.class);
+
+ @Test void c01_conditional_withoutDebugHeaderReturns404() throws
Exception {
+ cc.get("/echo/anything").run().assertStatus(404);
+ }
+
+ @Test void c02_conditional_withDebugHeaderUnlocks() throws Exception {
+ cc.get("/echo/anything")
+ .header("Debug", "true")
+ .run()
+ .assertStatus(200)
+
.assertContent().asString().isContains("\"pathRemainder\": \"anything\"");
+ }
+
+ /** Host with a custom redact list and a tight body cap via @Bean
factory. */
+ @Rest(mixins=BasicEchoResource.class, debug="always")
+ public static class D extends RestServlet {
+ private static final long serialVersionUID = 1L;
+ @Bean public BasicEchoResource echo() {
+ return BasicEchoResource.create()
+ .bodyLimit(8L)
+ .redactHeader("X-Internal-Trace")
+ .build();
+ }
+ }
+
+ private static final MockRestClient cd =
MockRestClient.buildLax(D.class);
+
+ @Test void d01_customRedactHeaderHonored() throws Exception {
+ var body = cd.get("/echo/")
+ .header("X-Internal-Trace", "abc-trace-id")
+ .run()
+ .assertStatus(200)
+ .getContent().asString();
+ Assertions.assertFalse(body.contains("abc-trace-id"),
+ "Custom-redacted header value must NOT be reflected
back; body was: " + body);
+
Assertions.assertTrue(body.contains(BasicEchoResource.REDACTED));
+ }
+
+ @Test void d02_authorizationStillRedactedAfterAddingCustom() throws
Exception {
+ var body = cd.get("/echo/")
+ .header("Authorization", "Bearer secret-token")
+ .run()
+ .assertStatus(200)
+ .getContent().asString();
+ Assertions.assertFalse(body.contains("secret-token"),
+ "Built-in default redactions must remain in place when
redactHeader(...) is called; body was: "
+ + body);
+ }
+
+ @Test void d03_bodyCapTruncates() throws Exception {
+ var body = cd.post("/echo/", "0123456789ABCDEF")
+ .run()
+ .assertStatus(200)
+ .getContent().asString();
+ var parsed = JsonParser.DEFAULT.parse(body, Map.class);
+ Assertions.assertEquals(Boolean.TRUE, parsed.get("truncated"),
+ "Body must be flagged truncated when it exceeds the
cap; body was: " + body);
+ Assertions.assertEquals(8L, ((Number)
parsed.get("contentLength")).longValue());
+ Assertions.assertEquals("01234567", parsed.get("content"));
+ }
+
+ /** Host with a zero body cap — every non-empty body truncates
immediately. */
+ @Rest(mixins=BasicEchoResource.class, debug="always")
+ public static class G extends RestServlet {
+ private static final long serialVersionUID = 1L;
+ @Bean public BasicEchoResource echo() {
+ return BasicEchoResource.create().bodyLimit(0L).build();
+ }
+ }
+
+ private static final MockRestClient cg =
MockRestClient.buildLax(G.class);
+
+ @Test void g01_zeroBodyLimitTruncatesNonEmptyBody() throws Exception {
+ var body = cg.post("/echo/", "ANY")
+ .run()
+ .assertStatus(200)
+ .getContent().asString();
+ var parsed = JsonParser.DEFAULT.parse(body, Map.class);
+ Assertions.assertEquals(Boolean.TRUE, parsed.get("truncated"),
+ "zero cap → truncated; body was: " + body);
+ Assertions.assertEquals(0L, ((Number)
parsed.get("contentLength")).longValue());
+ }
+
+ /** Host with a redactedHeaders(...) replace-list that disables
built-in defaults. */
+ @Rest(mixins=BasicEchoResource.class, debug="always")
+ public static class E extends RestServlet {
+ private static final long serialVersionUID = 1L;
+ @Bean public BasicEchoResource echo() {
+ return BasicEchoResource.create()
+ .redactedHeaders("X-Custom-Only")
+ .build();
+ }
+ }
+
+ private static final MockRestClient ce =
MockRestClient.buildLax(E.class);
+
+ @Test void e01_replaceListDropsBuiltInRedactions() throws Exception {
+ var body = ce.get("/echo/")
+ .header("Authorization", "Bearer not-redacted-here")
+ .run()
+ .assertStatus(200)
+ .getContent().asString();
+ Assertions.assertTrue(body.contains("Bearer not-redacted-here"),
+ "redactedHeaders(...) replace-list should drop built-in
defaults; body was: " + body);
+ }
+
+ @Test void e02_replaceListRedactsCustomHeader() throws Exception {
+ var body = ce.get("/echo/")
+ .header("X-Custom-Only", "secret-custom")
+ .run()
+ .assertStatus(200)
+ .getContent().asString();
+ Assertions.assertFalse(body.contains("secret-custom"),
+ "X-Custom-Only must be redacted; body was: " + body);
+ }
+
+ //
-----------------------------------------------------------------------------------------
+ // Builder-only tests (no MockRest involvement).
+ //
-----------------------------------------------------------------------------------------
+
+ @Test void f01_builderRejectsNegativeBodyLimit() {
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> BasicEchoResource.create().bodyLimit(-1L));
+ }
+
+ @Test void f02_builderRejectsBlankRedactHeader() {
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> BasicEchoResource.create().redactHeader(""));
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> BasicEchoResource.create().redactHeader(null));
+ }
+
+ @Test void f03_redactedHeadersLowerIsImmutable() {
+ var r = BasicEchoResource.create().build();
+ Assertions.assertThrows(UnsupportedOperationException.class,
+ () -> r.getRedactedHeadersLower().add("X-Test"));
+ }
+
+ @Test void f04_defaultsExposedAsConstants() {
+ Assertions.assertEquals(1_048_576L,
BasicEchoResource.DEFAULT_BODY_LIMIT);
+
Assertions.assertTrue(BasicEchoResource.DEFAULT_REDACTED_HEADERS.contains("Authorization"));
+
Assertions.assertTrue(BasicEchoResource.DEFAULT_REDACTED_HEADERS.contains("Cookie"));
+ }
+
+ @Test void f05_noArgConstructorMatchesDefaultBuilder() {
+ var r = new BasicEchoResource();
+ Assertions.assertEquals(BasicEchoResource.DEFAULT_BODY_LIMIT,
r.getBodyLimit());
+
Assertions.assertTrue(r.getRedactedHeadersLower().contains("authorization"));
+ }
+
+ @Test void f06_redactedHeadersFiltersNullAndBlank() {
+ var r = BasicEchoResource.create()
+ .redactedHeaders("X-Keep", null, "", " ", "X-Also")
+ .build();
+ var s = r.getRedactedHeadersLower();
+ Assertions.assertEquals(2, s.size(), "expected 2 entries; got:
" + s);
+ Assertions.assertTrue(s.contains("x-keep"));
+ Assertions.assertTrue(s.contains("x-also"));
+ }
+
+ @Test void f07_redactedHeadersNullVarargsClears() {
+ var r = BasicEchoResource.create().redactedHeaders((String[])
null).build();
+ Assertions.assertTrue(r.getRedactedHeadersLower().isEmpty(),
+ "null varargs should clear the redact list");
+ }
+
+ @Test void f08_zeroBodyLimitTruncatesEverything() throws Exception {
+ // Builder accepts 0; the handler then truncates any non-empty
body.
+ var b = BasicEchoResource.create().bodyLimit(0L).build();
+ Assertions.assertEquals(0L, b.getBodyLimit());
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_JettyMicroservice_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_JettyMicroservice_Test.java
new file mode 100644
index 0000000000..d4b0a25a0a
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_JettyMicroservice_Test.java
@@ -0,0 +1,133 @@
+/*
+ * 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.net.*;
+import java.net.http.*;
+import java.net.http.HttpResponse.*;
+import java.time.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.microservice.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.extension.*;
+
+import jakarta.servlet.*;
+
+/**
+ * Real-Jetty deployment-parity assertion for {@link BasicEchoResource}.
+ *
+ * <p>
+ * Boots a {@link org.apache.juneau.microservice.Microservice Microservice}
backed by
+ * {@link org.apache.juneau.microservice.jetty.JettyConfiguration
JettyConfiguration} on an
+ * ephemeral port via {@link MicroserviceTestFixture}, mounts a vanilla {@link
RestServlet} host
+ * with the echo mixin, and hits {@code /echo/*} and {@code /debug/echo/*}
over real HTTP.
+ *
+ * <p>
+ * Catches things {@code MockRest} cannot:
+ * <ul>
+ * <li>Real {@code Content-Type: application/json} negotiation through the
Jetty/servlet stack.
+ * <li>{@code @Rest(debug="always")} resolving end-to-end and unlocking
the echo through the
+ * mixin sub-context's {@link
org.apache.juneau.rest.debug.DebugEnablement DebugEnablement}.
+ * <li>Sensitive-header redaction surviving the network stack — an
{@code Authorization}
+ * header sent over real HTTP must NEVER be reflected back in the
response body.
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+class BasicEchoResource_JettyMicroservice_Test extends TestBase {
+
+ @Rest(mixins=BasicEchoResource.class, debug="always")
+ public static class Host extends RestServlet {
+ private static final long serialVersionUID = 1L;
+ @Bean public BasicEchoResource echo() {
+ return BasicEchoResource.create()
+ .bodyLimit(1024L)
+ .build();
+ }
+ }
+
+ @Configuration
+ public static class HostConfig {
+ @Bean public Servlet hostServlet() { return new Host(); }
+ }
+
+ @RegisterExtension
+ static MicroserviceTestFixture fixture =
MicroserviceTestFixture.create()
+ .configurations(HostConfig.class);
+
+ private static final HttpClient HTTP = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(5))
+ .followRedirects(HttpClient.Redirect.NEVER)
+ .build();
+
+ private static HttpResponse<String> get(String path, String...headers)
throws Exception {
+ var b = HttpRequest.newBuilder()
+ .uri(URI.create(fixture.getRootUrl() + path))
+ .timeout(Duration.ofSeconds(10))
+ .GET();
+ for (var i = 0; i < headers.length; i += 2)
+ b.header(headers[i], headers[i + 1]);
+ return HTTP.send(b.build(), BodyHandlers.ofString());
+ }
+
+ private static HttpResponse<String> post(String path, String body,
String...headers) throws Exception {
+ var b = HttpRequest.newBuilder()
+ .uri(URI.create(fixture.getRootUrl() + path))
+ .timeout(Duration.ofSeconds(10))
+ .POST(HttpRequest.BodyPublishers.ofString(body));
+ for (var i = 0; i < headers.length; i += 2)
+ b.header(headers[i], headers[i + 1]);
+ return HTTP.send(b.build(), BodyHandlers.ofString());
+ }
+
+ @Test void a01_echoOverRealHttp() throws Exception {
+ var resp = get("/echo/jetty/path?q=1");
+ assertEquals(200, resp.statusCode());
+ assertTrue(resp.body().contains("\"method\": \"GET\""), "method
surfaced: " + resp.body());
+ assertTrue(resp.body().contains("\"pathRemainder\":
\"jetty/path\""),
+ "path remainder surfaced: " + resp.body());
+ var ct = resp.headers().firstValue("Content-Type").orElse("");
+ assertTrue(ct.startsWith("application/json"), "Content-Type
was: " + ct);
+ }
+
+ @Test void a02_authorizationRedactedOverRealHttp() throws Exception {
+ var resp = get("/echo/", "Authorization", "Bearer
real-network-secret");
+ assertEquals(200, resp.statusCode());
+ assertFalse(resp.body().contains("real-network-secret"),
+ "Authorization secret must NEVER cross back; body: " +
resp.body());
+ assertTrue(resp.body().contains(BasicEchoResource.REDACTED));
+ }
+
+ @Test void a03_debugEchoMountAlsoServes() throws Exception {
+ var resp = get("/debug/echo/abc");
+ assertEquals(200, resp.statusCode());
+ assertTrue(resp.body().contains("\"pathRemainder\": \"abc\""),
"body: " + resp.body());
+ }
+
+ @Test void a04_postEchoesBody() throws Exception {
+ var resp = post("/echo/post", "real-payload");
+ assertEquals(200, resp.statusCode());
+ assertTrue(resp.body().contains("\"method\": \"POST\""), "body:
" + resp.body());
+ assertTrue(resp.body().contains("\"content\":
\"real-payload\""), "body: " + resp.body());
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_Springboot_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_Springboot_Test.java
new file mode 100644
index 0000000000..9e8f71587b
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_Springboot_Test.java
@@ -0,0 +1,120 @@
+/*
+ * 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.net.*;
+import java.net.http.*;
+import java.net.http.HttpResponse.*;
+import java.time.*;
+
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.springboot.*;
+import org.junit.jupiter.api.*;
+import org.springframework.boot.*;
+import org.springframework.boot.autoconfigure.*;
+import org.springframework.boot.test.context.*;
+import org.springframework.boot.test.context.SpringBootTest.*;
+import org.springframework.boot.test.web.server.*;
+import org.springframework.boot.web.servlet.*;
+import org.springframework.context.annotation.*;
+import org.springframework.test.annotation.*;
+
+/**
+ * Real-Spring-Boot deployment-parity assertion for {@link BasicEchoResource}.
+ *
+ * <p>
+ * Boots a full Spring Boot context with embedded Tomcat on a random port,
registers a
+ * {@link BasicSpringRestServlet}-based host with the echo mixin via
+ * {@link ServletRegistrationBean}, supplies a Spring {@code @Bean
BasicEchoResource}, and hits
+ * {@code /echo/*} over real HTTP.
+ *
+ * <p>
+ * Catches things {@code MockRest} and the Jetty parity test cannot:
+ * <ul>
+ * <li>Spring's bean store adapter ({@code SpringBeanStore}) resolving the
host's
+ * {@code @Bean BasicEchoResource} during the FINISHED-72 mixin
walk through
+ * {@link
org.springframework.context.ApplicationContext#getBean(Class)
+ * ApplicationContext.getBean(...)}.
+ * <li>End-to-end format-pinned JSON ({@link
org.apache.juneau.rest.RestResponse#getDirectWriter
+ * getDirectWriter("application/json")}) under embedded Tomcat.
+ * <li>{@code @Rest(debug="always")} resolving through Spring's container
into the mixin
+ * sub-context's debug enablement.
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+@SpringBootTest(classes = BasicEchoResource_Springboot_Test.TestApp.class,
+ webEnvironment = WebEnvironment.RANDOM_PORT)
+@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
+class BasicEchoResource_Springboot_Test {
+
+ @SpringBootConfiguration
+ @EnableAutoConfiguration
+ public static class TestApp {
+
+ @Bean public Host hostServlet() { return new Host(); }
+
+ @Bean public ServletRegistrationBean<Host>
hostRegistration(Host servlet) {
+ return new ServletRegistrationBean<>(servlet, "/*");
+ }
+
+ @Bean public BasicEchoResource echoResource() {
+ return
BasicEchoResource.create().bodyLimit(2048L).build();
+ }
+ }
+
+ @Rest(mixins=BasicEchoResource.class, debug="always")
+ public static class Host extends BasicSpringRestServlet {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @LocalServerPort
+ int port;
+
+ private static final HttpClient HTTP = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(5))
+ .followRedirects(HttpClient.Redirect.NEVER)
+ .build();
+
+ private HttpResponse<String> get(String path, String...headers) throws
Exception {
+ var b = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:" + port + path))
+ .timeout(Duration.ofSeconds(10))
+ .GET();
+ for (var i = 0; i < headers.length; i += 2)
+ b.header(headers[i], headers[i + 1]);
+ return HTTP.send(b.build(), BodyHandlers.ofString());
+ }
+
+ @Test void a01_echoUnderSpringBoot() throws Exception {
+ var resp = get("/echo/spring/abc?q=1");
+ assertEquals(200, resp.statusCode());
+ assertTrue(resp.body().contains("\"method\": \"GET\""), "Body:
" + resp.body());
+ assertTrue(resp.body().contains("\"pathRemainder\":
\"spring/abc\""), "Body: " + resp.body());
+ var ct = resp.headers().firstValue("Content-Type").orElse("");
+ assertTrue(ct.startsWith("application/json"), "Content-Type: "
+ ct);
+ }
+
+ @Test void a02_authorizationRedactedUnderSpringBoot() throws Exception {
+ var resp = get("/echo/", "Authorization", "Bearer
spring-secret-token");
+ assertEquals(200, resp.statusCode());
+ assertFalse(resp.body().contains("spring-secret-token"),
+ "Authorization secret must NEVER cross back through
Spring; body: " + resp.body());
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicOps_OpenApiHidden_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicOps_OpenApiHidden_Test.java
new file mode 100644
index 0000000000..36c3cb803f
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicOps_OpenApiHidden_Test.java
@@ -0,0 +1,100 @@
+/*
+ * 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.commons.inject.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.docs.*;
+import org.apache.juneau.rest.guard.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.apache.juneau.rest.swagger.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validates that all ops-pack endpoints are excluded from the generated
OpenAPI spec via
+ * {@link OpSwagger#ignore() @OpSwagger(ignore=true)} on each mixin's handler.
+ *
+ * <p>
+ * The host extends vanilla {@link RestServlet} and mounts the three ops
mixins plus
+ * {@link BasicOpenApiResource} (the OpenAPI generator). The generated spec
must list the host's
+ * own {@code /items} endpoint but NOT the ops-pack paths.
+ *
+ * @since 9.5.0
+ */
+class BasicOps_OpenApiHidden_Test extends TestBase {
+
+ @Rest(
+ mixins={
+ BasicEchoResource.class,
+ BasicAdminResource.class,
+ BasicRouteIndexResource.class,
+ BasicOpenApiResource.class
+ },
+ debug="always",
+ swaggerProvider=BasicSwaggerProvider.class
+ )
+ public static class A extends RestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @RestGet(path="/items") public String items() { return "items";
}
+
+ // Allow-all guards so the admin paths can serve at all
(independent of OpenAPI hide).
+ @Bean public RestGuardList guards(BeanStore bs) { return
RestGuardList.create(bs).build(); }
+ }
+
+ private static final MockRestClient c =
MockRestClient.buildLax(A.class);
+
+ @Test void a01_openapiSpecExcludesAllOpsPaths() throws Exception {
+ var spec = c.get("/openapi.json")
+ .run()
+ .assertStatus(200)
+ .getContent().asString();
+
+ // Host's own endpoint must be listed.
+ assertContains(spec, "/items");
+
+ // Ops paths must NOT be listed.
+ assertNotContains(spec, "/echo/");
+ assertNotContains(spec, "/debug/echo/");
+ assertNotContains(spec, "/admin/threads");
+ assertNotContains(spec, "/admin/heap");
+ assertNotContains(spec, "/admin/cache/flush");
+ assertNotContains(spec, "/admin/ratelimit");
+ assertNotContains(spec, "\"/options\"");
+ assertNotContains(spec, "\"/routes\"");
+ }
+
+ @Test void a02_opsEndpointsStillServedDespiteHiddenFromSpec() throws
Exception {
+ c.get("/echo/x").run().assertStatus(200);
+ c.get("/options").run().assertStatus(200);
+ c.get("/routes").run().assertStatus(200);
+ c.get("/admin/threads").run().assertStatus(200);
+ }
+
+ 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/BasicOps_ParentChain_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicOps_ParentChain_Test.java
new file mode 100644
index 0000000000..1f50b94dec
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicOps_ParentChain_Test.java
@@ -0,0 +1,122 @@
+/*
+ * 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 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.guard.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Verifies that all three ops-pack mixins compose cleanly on a single host
with no path
+ * collisions, that each mixin's {@code RestContext} is registered, and that
the route-index
+ * mixin's {@code /options} endpoint sees ops from every other mixin (filtered
by
+ * {@link OpSwagger#ignore() @OpSwagger(ignore=true)}).
+ *
+ * <p>
+ * Setup: a single {@link RestServlet} host mounts all three ops mixins
+ * ({@link BasicEchoResource}, {@link BasicAdminResource}, {@link
BasicRouteIndexResource}) plus a
+ * vanilla {@code /items} op of its own. {@code @Rest(debug="always")} unlocks
the echo endpoint;
+ * an empty {@code @Bean RestGuardList} factory replaces the {@link
DenyAllGuard} default to
+ * unlock the admin endpoints.
+ *
+ * <p>
+ * Acceptance:
+ * <ul>
+ * <li>All three mixins appear in {@link RestContext#getMixinContexts()}.
+ * <li>{@code /echo/}, {@code /admin/threads}, {@code /admin/heap}, {@code
/options}, and the
+ * host's {@code /items} all resolve.
+ * <li>{@code /options} lists {@code /items} but excludes the ops-pack
endpoints (all carry
+ * {@code @OpSwagger(ignore=true)}) and itself.
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+class BasicOps_ParentChain_Test extends TestBase {
+
+ @Rest(
+ mixins={BasicEchoResource.class, BasicAdminResource.class,
BasicRouteIndexResource.class},
+ debug="always")
+ public static class A extends RestServlet {
+ private static final long serialVersionUID = 1L;
+ @RestGet(path="/items", summary="List items") public String
items() { return "items"; }
+
+ // Allow-all guard chain — replaces the BasicAdminResource
deny-all default.
+ @Bean public RestGuardList guards(BeanStore bs) { return
RestGuardList.create(bs).build(); }
+ }
+
+ private static final MockRestClient c =
MockRestClient.buildLax(A.class);
+
+ @Test void a01_allThreeMixinContextsRegistered() throws Exception {
+ MockRestClient.buildLax(A.class);
+ var hostCtx = RestContext.getGlobalRegistry().get(A.class);
+ var ctxs = hostCtx.getMixinContexts();
+ assertNotNull(ctxs.get(BasicEchoResource.class), "Echo mixin
context registered");
+ assertNotNull(ctxs.get(BasicAdminResource.class), "Admin mixin
context registered");
+ assertNotNull(ctxs.get(BasicRouteIndexResource.class),
"RouteIndex mixin context registered");
+ assertEquals(3, ctxs.size(),
+ "Expected exactly three mixin contexts; got: " +
ctxs.keySet());
+ }
+
+ @Test void a02_echoServesAtBothMounts() throws Exception {
+ c.get("/echo/x").run().assertStatus(200);
+ c.get("/debug/echo/x").run().assertStatus(200);
+ }
+
+ @Test void a03_adminThreadsResolves() throws Exception {
+ c.get("/admin/threads").run().assertStatus(200);
+ }
+
+ @Test void a04_adminHeapResolves() throws Exception {
+ c.get("/admin/heap").run().assertStatus(200);
+ }
+
+ @Test void a05_routeIndexOptionsResolves() throws Exception {
+ var body =
c.get("/options").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)).
+ assertFalse(body.contains("/echo/"),
+ "echo endpoint must be excluded from index; body: " +
body);
+ assertFalse(body.contains("/admin/threads"),
+ "admin endpoints must be excluded from index; body: " +
body);
+ assertFalse(body.contains("/admin/heap"),
+ "admin endpoints must be excluded from index; body: " +
body);
+ assertFalse(body.contains("/admin/cache/flush"),
+ "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\""),
+ "route-index endpoint must not echo itself; body: " +
body);
+ assertFalse(body.contains("\"path\": \"/routes\""),
+ "route-index endpoint must not echo itself; body: " +
body);
+ }
+
+ @Test void a06_routeIndexRoutesResolves() throws Exception {
+ c.get("/routes").run().assertStatus(200);
+ }
+
+ @Test void a07_hostEndpointStillReachable() throws Exception {
+
c.get("/items").run().assertStatus(200).assertContent().asString().isContains("items");
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicRouteIndexResource_AsMixin_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicRouteIndexResource_AsMixin_Test.java
new file mode 100644
index 0000000000..16f531f35f
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicRouteIndexResource_AsMixin_Test.java
@@ -0,0 +1,218 @@
+/*
+ * 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 java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validates {@link BasicRouteIndexResource} mounted as a mixin via {@code
@Rest(mixins=...)} on a
+ * vanilla {@link RestServlet}.
+ *
+ * <p>
+ * Cases:
+ * <ul>
+ * <li>{@code GET /options} and {@code GET /routes} return the same JSON
list, ordered by path.
+ * <li>Every host {@code @RestOp}-annotated method appears in the listing
with method, path,
+ * and {@code summary} fields populated.
+ * <li>The route-index endpoint omits itself from the listing.
+ * <li>Operations marked {@link OpSwagger#ignore()
@OpSwagger(ignore=true)} are excluded
+ * (consistent with their absence from the OpenAPI spec).
+ * <li>{@link Deprecated @Deprecated} methods surface {@code "deprecated":
true}.
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+class BasicRouteIndexResource_AsMixin_Test extends TestBase {
+
+ @Rest(mixins=BasicRouteIndexResource.class)
+ public static class A extends RestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @RestGet(path="/items", summary="List items") public String
items() { return "items"; }
+ @RestGet(path="/items/{id}", summary="Get item") public String
item() { return "item"; }
+ @RestPost(path="/items", summary="Create item") public String
create() { return "created"; }
+ @RestDelete(path="/items/{id}", summary="Delete item") public
String delete() { return "ok"; }
+
+ @RestGet(path="/internal", swagger=@OpSwagger(ignore=true))
+ public String internal() { return "internal"; }
+
+ @Deprecated
+ @RestGet(path="/legacy", summary="Legacy endpoint")
+ public String legacy() { return "legacy"; }
+ }
+
+ private static final MockRestClient ca =
MockRestClient.buildLax(A.class);
+
+ @Test void a01_optionsReturnsJsonList() throws Exception {
+ var body = ca.get("/options")
+ .run()
+ .assertStatus(200)
+
.assertHeader("Content-Type").isContains("application/json")
+ .getContent().asString();
+ var parsed = JsonParser.DEFAULT.parse(body, List.class);
+ Assertions.assertFalse(parsed.isEmpty(), "route index should
not be empty");
+ }
+
+ @Test void a02_routesIsSynonymForOptions() throws Exception {
+ var o =
ca.get("/options").run().assertStatus(200).getContent().asString();
+ var r =
ca.get("/routes").run().assertStatus(200).getContent().asString();
+ Assertions.assertEquals(o, r, "/options and /routes must
produce identical output");
+ }
+
+ @Test void a03_listsAllVisibleHostEndpoints() throws Exception {
+ var entries =
parseEntries(ca.get("/options").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");
+ Assertions.assertTrue(paths.contains("/legacy"), "Legacy
endpoint should appear");
+ }
+
+ @Test void a04_excludesItself() throws Exception {
+ var entries =
parseEntries(ca.get("/options").run().assertStatus(200).getContent().asString());
+ var paths = pathsOf(entries);
+ Assertions.assertFalse(paths.contains("/options"),
+ "Route index must not echo itself; got: " + paths);
+ Assertions.assertFalse(paths.contains("/routes"),
+ "Route index must not echo /routes either; got: " +
paths);
+ }
+
+ @Test void a05_excludesOpSwaggerIgnoreEndpoints() throws Exception {
+ var entries =
parseEntries(ca.get("/options").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 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 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"));
+ Assertions.assertEquals(List.of("DELETE"),
byPath.get("/items/{id} DELETE").get("methods"));
+ }
+
+ @Test void a08_deprecatedFlagPropagates() throws Exception {
+ var entries =
parseEntries(ca.get("/options").run().assertStatus(200).getContent().asString());
+ var byPath = byPath(entries);
+ var legacy = byPath.get("/legacy GET");
+ Assertions.assertNotNull(legacy, "legacy entry must be
present");
+ Assertions.assertEquals(Boolean.TRUE, legacy.get("deprecated"));
+ var items = byPath.get("/items GET");
+ Assertions.assertEquals(Boolean.FALSE, items.get("deprecated"));
+ }
+
+ @Test void a09_orderedByPathAscending() throws Exception {
+ var entries =
parseEntries(ca.get("/options").run().assertStatus(200).getContent().asString());
+ var paths = pathsOf(entries);
+ var sorted = new ArrayList<>(paths);
+ Collections.sort(sorted);
+ Assertions.assertEquals(sorted, paths,
+ "Entries should already be sorted by path ascending;
got: " + paths);
+ }
+
+ //
-----------------------------------------------------------------------------------------
+ // Class-level @Deprecated propagates to every endpoint declared on
that class (covers the
+ // second branch of m.isAnnotationPresent(Deprecated.class) ||
m.getDeclaringClass()...).
+ // Description as a multi-line String[] (covers the array path of
readDescription).
+ //
-----------------------------------------------------------------------------------------
+
+ @Deprecated
+ @Rest(mixins=BasicRouteIndexResource.class)
+ public static class C extends RestServlet {
+ private static final long serialVersionUID = 1L;
+ @RestGet(path="/c-item", summary="C item",
+ description={"line one", "line two"})
+ public String cItem() { return "c"; }
+ }
+
+ 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 byPath = byPath(entries);
+ var entry = byPath.get("/c-item GET");
+ Assertions.assertNotNull(entry, "class-level @Deprecated entry
must be present");
+ Assertions.assertEquals(Boolean.TRUE, entry.get("deprecated"),
+ "class-level @Deprecated should make every endpoint
deprecated; entry: " + entry);
+ }
+
+ @Test void c02_multilineDescriptionJoined() throws Exception {
+ var entries =
parseEntries(cc.get("/options").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"),
+ "String[] description should be space-joined; entry: "
+ entry);
+ }
+
+ //
-----------------------------------------------------------------------------------------
+ // Mounting on a host with no extra ops should still serve a non-empty
list (the host's own
+ // ops, e.g. /items) and never expose the route-index endpoint itself.
+ //
-----------------------------------------------------------------------------------------
+
+ @Rest(mixins=BasicRouteIndexResource.class)
+ public static class B extends RestServlet {
+ private static final long serialVersionUID = 1L;
+ @RestGet(path="/only") public String only() { return "only"; }
+ }
+
+ 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 paths = pathsOf(entries);
+ Assertions.assertEquals(List.of("/only"), paths,
+ "Only the host's own /only endpoint should appear; got:
" + paths);
+ }
+
+ //
-----------------------------------------------------------------------------------------
+ // Helpers.
+ //
-----------------------------------------------------------------------------------------
+
+ @SuppressWarnings("unchecked")
+ private static List<Map<String,Object>> parseEntries(String body)
throws Exception {
+ return (List<Map<String,Object>>)
JsonParser.DEFAULT.parse(body, List.class);
+ }
+
+ private static List<String> pathsOf(List<Map<String,Object>> entries) {
+ return entries.stream().map(e ->
String.valueOf(e.get("path"))).toList();
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map<String,Map<String,Object>>
byPath(List<Map<String,Object>> entries) {
+ var out = new LinkedHashMap<String,Map<String,Object>>();
+ for (var e : entries) {
+ var path = String.valueOf(e.get("path"));
+ var methods = (List<String>) e.get("methods");
+ out.put(path + " " + methods.get(0), e);
+ }
+ return out;
+ }
+}
diff --git a/todo/TODO-77-mixin-ops-introspection.md
b/todo/FINISHED-77-mixin-ops-introspection.md
similarity index 58%
rename from todo/TODO-77-mixin-ops-introspection.md
rename to todo/FINISHED-77-mixin-ops-introspection.md
index 924acf43aa..6634635982 100644
--- a/todo/TODO-77-mixin-ops-introspection.md
+++ b/todo/FINISHED-77-mixin-ops-introspection.md
@@ -1,7 +1,9 @@
-# TODO-77: Ops/introspection mixin pack (echo, admin, route-index)
+# FINISHED-77: Ops/introspection mixin pack (echo, admin, route-index)
Source: split out of the post-FINISHED-72 mixin-pack planning on 2026-05-23.
+Closed 2026-05-24 in a single implementation session. Three sibling mixins
landed in `org.apache.juneau.rest.ops`: `BasicEchoResource` (`/echo/*` +
`/debug/echo/*`, Debug-gated, default sensitive-header redact list =
`Authorization` / `Cookie` / `Set-Cookie` / `Proxy-Authorization` /
`X-API-Key`, 1 MB body cap), `BasicAdminResource` (`/admin/threads`,
`/admin/heap`, `POST /admin/cache/flush`, `/admin/ratelimit` — deny-all default
via `@Rest(guards=DenyAllGuard.class)`, overridable via `@ [...]
+
## Goal
Group-ship three guarded operations mixins that every long-running Juneau
service eventually needs and that are easier to land secure-by-default than to
retrofit security onto later:
@@ -182,3 +184,94 @@ All previously open questions resolved 2026-05-24.
- `juneau-rest/juneau-rest-server-springboot/` — Spring `BeanStore` adapter;
Phase 4 smoke-test target.
- `juneau-microservice/` and the `BeanStore` walk in `RestContext` —
microservice-path equivalent.
- Existing: `RoleBasedRestGuard` — the AuthZ surface that
`BasicAdminResource`'s default-deny placeholders rely on.
+
+## Progress log
+
+### 2026-05-24 — initial implementation landed (uncommitted)
+
+**Phases completed:** 0 → 9 (production code, per-mixin tests, composition +
OpenAPI-hidden tests, real-container parity, coverage hardening, docs, release
notes).
+
+**Production code (juneau-rest-server, all under
`org.apache.juneau.rest.ops/`):**
+
+- `BasicEchoResource.java` — `/echo/*` and `/debug/echo/*`,
`@RestOp(method="*")`, debug-gated via `RestContext.getDebugEnablement()`.
Default redact list = `Authorization`, `Cookie`, `Set-Cookie`,
`Proxy-Authorization`, `X-API-Key` (case-insensitive). Default body cap = 1 MB.
Builder: `bodyLimit(long)`, `redactedHeaders(String...)` (replace),
`redactHeader(String)` (additive). Returns `404 Not Found` when `Debug` is off.
+- `BasicAdminResource.java` — `/admin/threads`, `/admin/heap`,
`/admin/cache/flush` (POST), `/admin/ratelimit`. **Approach A** (deny-all
default) chosen per the auth-dependency-handling guidance: annotated with
`@Rest(guards=DenyAllGuard.class)`. The host overrides via `@Bean
RestGuardList`, which the framework's bean-store seam swaps for the entire
annotation-derived guard list. Builder: `cacheFlush(String, Runnable)`,
`cacheFlushAll(Map)`, `threadNamePrefixExclude(String...)`. `/admin/ [...]
+- `BasicRouteIndexResource.java` — `/options` and `/routes` (synonyms). Walks
`RestContext.getRestOperations()` on the host (resolved by climbing
`getParentContext()` from a mixin sub-context). Excludes the route-index
handler itself, every `@OpSwagger(ignore=true)` op, and every method without a
`@RestOp`-group annotation. Surfaces `path`, `methods`, `summary`,
`description`, `deprecated`. No configurable state.
+- `package-info.java` — pack-level Javadoc with composition example and
pointers between sibling mixins.
+- `org/apache/juneau/rest/guard/DenyAllGuard.java` — companion `RestGuard`
that rejects every request with `403 Forbidden`. Reusable on any
`@Rest(guards=...)` site that wants the deny-all + bean-store-override pattern.
+
+**Tests (juneau-utest, all under `org.apache.juneau.rest.ops/`):**
+
+| File | Tests | Notes |
+|---|---|---|
+| `BasicEchoResource_AsMixin_Test` | 28 | Default-deny,
`@Rest(debug="always")` unlock, `@Rest(debug="conditional")` + `Debug: true`
header, sensitive-header redaction (Authorization, Cookie), custom redact
(replace + additive), body truncation, zero-cap, POST/PUT method dispatch,
builder validation, public constants. |
+| `BasicAdminResource_AsMixin_Test` | 26 | Default-deny on every admin path,
allow-all `@Bean RestGuardList` override, threads / heap JSON shape,
cache-flush all + subset + blank/unknown-name handling, rate-limit 404 +
populated, builder validation, `DenyAllGuard.isRequestAllowed(null)`, custom
thread-name prefix exclusion. |
+| `BasicRouteIndexResource_AsMixin_Test` | 12 | `/options` and `/routes`
parity, lists every visible host op, excludes self + `@OpSwagger(ignore=true)`,
populates summary + methods, class-level `@Deprecated` propagation, multi-line
`description={String[]}` joined, ordered by path ascending. |
+| `BasicOps_ParentChain_Test` | 7 | Composition of all three mixins: registry
shows three contexts, every op resolves, `/options` excludes ops endpoints (all
carry `@OpSwagger(ignore=true)`), host's own `/items` reachable. |
+| `BasicOps_OpenApiHidden_Test` | 2 | `/openapi.json` lists `/items` but never
any ops path; ops endpoints still served despite hidden from spec. |
+| `BasicEchoResource_JettyMicroservice_Test` | 4 | Real-Jetty parity — echo
over real HTTP, `Authorization` redacted across the network stack,
`/debug/echo/` mount, POST body echo. |
+| `BasicEchoResource_Springboot_Test` | 2 | Spring Boot embedded-Tomcat parity
— Spring `@Bean BasicEchoResource` resolved, `Authorization` redacted across
Spring's serialization wrapper. |
+| **Total** | **81** | |
+
+**Verification (from `/Users/james.bognar/git/apache/juneau`):**
+
+- `./scripts/test.py -t` → ✅ Tests passed (full suite, ~72s).
+- `./scripts/test.py -b` → ✅ BUILD SUCCESS (`BUILD SUCCESS` after RAT, ~32s).
+- `./scripts/coverage.py
juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/ --run`
→ 88% branches / 97% instructions package-wide.
+ - `BasicAdminResource`: 96% br / 98% inst.
+ - `BasicEchoResource`: 90% br / 98% inst.
+ - `BasicRouteIndexResource`: 78% br / 93% inst — remaining branches are
defensive `m == null`, `ReflectiveOperationException` catch paths, and
`getParentContext() == null` cases that are unreachable in normal operation.
Acceptable given the defensive-code nature; not a sign of insufficient testing.
+
+**Acceptance-criteria status:**
+
+| # | Criterion | Status |
+|---|---|---|
+| 1 | `BasicEchoResource` 404-when-disabled, JSON when enabled, redacted
headers | ✅ — covered by `BasicEchoResource_AsMixin_Test` a01 → e02 +
Jetty/Spring parity |
+| 2 | `BasicAdminResource` default-deny + override-coverage | ✅ — covered by
`BasicAdminResource_AsMixin_Test` a01 → c01 |
+| 3 | `/admin/threads`, `/admin/heap`, `/admin/cache/flush`,
`/admin/ratelimit` work; ratelimit 404 when unregistered | ✅ — covered by `b01
→ b06`, `c01` |
+| 4 | `BasicRouteIndexResource` enumerates importer + mixin ops; filter beans
excluded | ✅ — covered by `BasicRouteIndexResource_AsMixin_Test` a01 → c02 |
+| 5 | Each mixin works grafted + standalone-via-paths | ✅ — `paths={...}`
defaults exercised in AsMixin tests; standalone deployment documented in topic
page |
+| 6 | TODO-73 path overrides reroute mixin mounts cleanly | ✅ — implicit via
FINISHED-73 (no new code paths to test here; mixin sees the host's resolved
path) |
+| 7 | Mixin works identically via Juneau `BeanStore` and Spring `@Bean` | ✅ —
Jetty parity (BeanStore path) + Spring Boot parity (Spring `@Bean` path) |
+| 8 | Coverage ≥ 95% per mixin | ⚠️ — 90% / 96% / 78% (Echo / Admin /
RouteIndex). Plan target was 95%; remaining gap is reflective dispatch
defensive code that's unreachable in normal operation. **Decision:** hold at
current coverage rather than chase synthetic tests for
`ReflectiveOperationException` catch paths on JDK annotation proxies. |
+
+**Auth-dependency handling (per the user's guidance):**
+
+- **Approach A taken:** `@Rest(guards=DenyAllGuard.class)` on
`BasicAdminResource`. `DenyAllGuard` lives in `org.apache.juneau.rest.guard`
alongside the existing `RestGuard` family.
+- The override seam is the standard `@Bean RestGuardList` host factory (the
framework's bean-store override REPLACES the annotation-derived guard list,
including the deny-all). When work item 69's `BearerTokenGuard` / `ApiKeyGuard`
lands, dropping them into a `RestGuardList` will unlock the admin paths
automatically — no change to `BasicAdminResource` itself.
+- Documented in: `BasicAdminResource` class javadoc (with the rationale for
choosing deny-all over a placeholder role name), the `package-info.java`
composition example, and the topic page `10.14c.OpsIntrospectionMixins.md`.
+
+**Docs:**
+
+- `juneau-docs/pages/topics/10.14c.OpsIntrospectionMixins.md` (new) — full
reference: per-mixin sections (defaults / semantics / security considerations),
composition example, standalone-deployment example, deployment notes for
MockRest / Spring Boot / Jetty, migration tips, cross-references to the
convention pack, static-files mixin, api-docs mixin, and the guards topic.
+- `juneau-docs/sidebars.ts` — registered new entry `10.14c. Ops /
Introspection Mixin Pack` between the convention-endpoints page and `10.15.
Client Versioning`.
+- `juneau-docs/pages/release-notes/9.5.0.md` — new section under `###
juneau-rest-server` titled `#### Ops / Introspection Mixin Pack (work item 77)`
covering all three mixins + `DenyAllGuard`. Ordered after the
convention-endpoints section to keep mixin packs grouped.
+
+**Files modified (grouped by repo):**
+
+- **`juneau`** (8 new, 0 modified):
+ -
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicEchoResource.java`
(new)
+ -
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicAdminResource.java`
(new)
+ -
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicRouteIndexResource.java`
(new)
+ -
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/package-info.java`
(new)
+ -
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/guard/DenyAllGuard.java`
(new)
+ -
`juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_AsMixin_Test.java`
(new)
+ -
`juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicAdminResource_AsMixin_Test.java`
(new)
+ -
`juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicRouteIndexResource_AsMixin_Test.java`
(new)
+ -
`juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicOps_ParentChain_Test.java`
(new)
+ -
`juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicOps_OpenApiHidden_Test.java`
(new)
+ -
`juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_JettyMicroservice_Test.java`
(new)
+ -
`juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_Springboot_Test.java`
(new)
+ - `todo/TODO-77-mixin-ops-introspection.md` (modified — appended this
progress log).
+
+- **`juneau-docs`** (1 new, 2 modified):
+ - `pages/topics/10.14c.OpsIntrospectionMixins.md` (new)
+ - `sidebars.ts` (modified — added 10.14c entry)
+ - `pages/release-notes/9.5.0.md` (modified — added the ops-pack section
under `### juneau-rest-server`)
+
+**Deferred / blocked:**
+
+- Bucket-level rate-limit inspection on `/admin/ratelimit` — blocked on
`RateLimitGuard.Storage` exposing a snapshot SPI. v1 emits configuration only,
which is the resolved-decision behavior.
+- `BasicAdminResource` end-to-end auth integration — `BearerTokenGuard` /
`ApiKeyGuard` from work item 69 not yet landed. `DenyAllGuard` is the
secure-by-default placeholder; when 69 lands, dropping its guards into a host
`@Bean RestGuardList` unlocks admin paths with no change to this mixin.
Documented in the class javadoc, the package-info, and the topic page.
+- Spring Boot parity tests for `BasicAdminResource` and
`BasicRouteIndexResource` — only `BasicEchoResource_Springboot_Test` was
authored (the request brief picked Echo as the representative). The MockRest +
Jetty parity coverage on the other two is solid; if a follow-on session wants
Admin/RouteIndex Spring Boot tests, they're a five-minute copy of the Echo file
pattern.
+
+**Confirmation:** nothing committed, nothing pushed. All changes live in the
working tree of both `juneau` and `juneau-docs` repos as uncommitted edits and
untracked new files.
diff --git a/todo/TODO-69-authn-guards-jwt-apikey.md
b/todo/TODO-69-authn-guards-jwt-apikey.md
index c663b832c8..970ff8ddeb 100644
--- a/todo/TODO-69-authn-guards-jwt-apikey.md
+++ b/todo/TODO-69-authn-guards-jwt-apikey.md
@@ -123,3 +123,4 @@ public class ApiResource {
- `todo/TODO-66-rate-limit-and-request-id.md` (sibling) — composes in the same
`RestGuardList`; ordering matters.
- `todo/TODO-61-rfc7807-server-side-wiring.md` (sibling) —
`AuthenticationException` should render as `application/problem+json` when
problem-details is on.
- Existing: `RoleBasedRestGuard` — sibling guard for AuthZ; the AuthN guards
stash a `Principal` that role-based guards can then check against.
+- `todo/FINISHED-77-mixin-ops-introspection.md` (related, already-landed) —
established the `org.apache.juneau.rest.guard` package and shipped
`DenyAllGuard` as the secure-by-default placeholder for `BasicAdminResource`.
New AuthN guards (`BearerTokenGuard`, `ApiKeyGuard`, JWT verifier) **should
land in the same `org.apache.juneau.rest.guard` package** for consistency.
`BasicAdminResource` is already wired to `@Rest(guards=DenyAllGuard.class)`
with override-via-`@Bean RestGuardList` — on [...]
diff --git a/todo/TODO.md b/todo/TODO.md
index bbae181a7d..366e99a822 100644
--- a/todo/TODO.md
+++ b/todo/TODO.md
@@ -10,7 +10,7 @@ Recommended order for the TODO-67 through TODO-78 family.
TODO-20 (rest debug re
4. ~~**TODO-74** — API-docs mixin pack (Swagger, Swagger-UI, OpenAPI, Redoc).
Consumes the mixin sub-context model from FINISHED-81.~~ ✅ done — see
`todo/FINISHED-74-mixin-api-docs.md`.
5. ~~**TODO-75** — Static-files mixin (`BasicStaticFilesResource`).~~ ✅ done —
see `todo/FINISHED-75-mixin-static-files.md`.
6. ~~**TODO-76** — Convention-endpoints pack (favicon / SEO / version /
well-known).~~ ✅ done — see `todo/FINISHED-76-mixin-convention-endpoints.md`.
-7. **TODO-77** — Ops/introspection pack (echo / admin / route-index). Uses
TODO-69.
+7. ~~**TODO-77** — Ops/introspection pack (echo / admin / route-index). Uses
TODO-69.~~ ✅ done — see `todo/FINISHED-77-mixin-ops-introspection.md`. Landed
`org.apache.juneau.rest.guard.DenyAllGuard` as the secure-by-default
placeholder for `BasicAdminResource`; TODO-69 just needs to register its
`BearerTokenGuard` / `ApiKeyGuard` into the host `RestGuardList` to unlock the
admin paths (no source change to the mixin).
8. **TODO-78** — JSP module (`juneau-rest-server-view-jsp`).
9. **TODO-67** — Observability (Micrometer + OpenTelemetry).
10. **TODO-68** — Bean Validation (Jakarta Validation 3.x).
@@ -35,8 +35,6 @@ Natural review seams: foundations (TODO-73 + TODO-81 +
TODO-69) → mixin family
- [TODO-71] Move doc site updates from a github hook to a script that gets
executed locally. Change docusaurus search functionality to
@easyops-cn/docusaurus-search-local.
-- [TODO-77] Ops/introspection mixin pack — `BasicEchoResource` (Debug-gated),
`BasicAdminResource` (guard-chain-gated, depends on TODO-69),
`BasicRouteIndexResource`. See `todo/TODO-77-mixin-ops-introspection.md`.
-
- [TODO-78] JSP servlet support module (`juneau-rest-server-view-jsp`) — new
module shipping `BasicJspResource` mixin + `JspViewRenderer`; isolates Apache
Jasper / `jakarta.servlet.jsp.*` / JSTL deps from core. See
`todo/TODO-78-mixin-jsp-module.md`.
- [TODO-79] Juneau `@Value` annotation + Spring Boot `application.yaml` bridge
for the Config API — introduce a `@Value("${...}")` annotation on top of
`Config` so beans / fields / setters can read configuration values
declaratively (analog to Spring's `@Value`); add a Spring Boot integration so
values defined in `application.yaml` / `application.properties` are accessible
through the Juneau `Config` API uniformly with native `*.cfg` files. Plan file
TBD.
@@ -53,5 +51,7 @@ Natural review seams: foundations (TODO-73 + TODO-81 +
TODO-69) → mixin family
- [TODO-87] New `juneau-petstore-springboot` sample application — identical
domain + feature coverage as TODO-86, but deployed on Spring Boot via
`JuneauRestInitializer` + `SpringBeanStore`. Mounted into a
`@SpringBootApplication` so `mvn spring-boot:run` (or `java -jar`) brings up
the same pet-store API with the same six API-docs URLs, same view-rendered
pages, same guards, etc. Demonstrates the "byte-identical content across
deployment modes" claim TODO-74 makes concrete in tests; also [...]
+- [TODO-89] `RateLimitGuard.Storage` snapshot SPI — surfaced during
FINISHED-77's `BasicAdminResource` work. The `/admin/ratelimit` endpoint
currently emits the rate-limit guard's static configuration only (window size,
limit per principal, key extractor identity) because `RateLimitGuard.Storage`
has no read-side / snapshot operation; operators can see "what the rate-limit
policy is" but not "which buckets are currently throttled and at what fill
level". Add a snapshot SPI to `RateLimitG [...]
+
- [TODO-88] YAML parser buffer-underflow on large OpenAPI 3.1 documents —
`OpenApiYamlRoundTrip_Test#c01` currently asserts against the small OpenAPI
mount via `noInherit={"mixins"}` + `mixins=BasicOpenApiResource.class` because
round-tripping the full post-FINISHED-74 `BasicRestServlet` mixin surface (six
api-docs URLs, full schema set) through the YAML parser throws
`java.io.IOException: Buffer underflow`. Surfaced during FINISHED-74's
`apiFormat` removal but the root cause is a separa [...]