Skip to main content

Ops / Introspection Mixin Pack

The Juneau REST server ships with a three-class ops / introspection mixin pack in org.apache.juneau.rest.server.ops covering the operational surface every long-running service eventually grows: a request-echo endpoint for round-trip debugging, a JVM admin surface for thread / heap / cache / rate-limit visibility, and a route-index endpoint for navigation. Each mixin is a single-purpose @Rest-annotated resource designed to be grafted into your servlet via @Rest(mixins=...) — pick the URLs you want, configure them via a @Bean factory, and leave the rest unmounted.

The three mixins at a glance​

MixinDefault pathsDefault behaviorWhy it exists
EchoMixin/echo/*404 Not Found by default — reachable only when explicitly enabled (EchoMixin.create().enabled() or the ${juneau.echo.enabled:false} fallback), independent of logger level. When enabled, returns a JSON body reflecting the inbound method, path, query string, headers (sensitive ones redacted), query params, attributes, and bounded body capture.Round-trip request introspection — invaluable for diagnosing proxy / mTLS / auth-header issues without spinning up a packet capture.
AdminMixin/admin/threads, /admin/heap, /admin/cache/flush, /admin/ratelimit403 Forbidden until the host registers a @Bean RestGuardList. Once unlocked: GET /admin/threads (JSON thread dump), GET /admin/heap (Runtime + MemoryMXBean stats), POST /admin/cache/flush (run registered hooks), GET /admin/ratelimit (registered RateLimitGuard beans — config + live per-key bucket snapshot).JVM operational visibility on a deployed service — the bare minimum to triage a stuck request, OOM, or stale cache without redeploying.
RouteIndexMixin/options, /routesJSON list of every @RestOp-annotated method on the host (and its mixins), excluding @OpSwagger(ignore=true) ops and itself. Each entry: path, methods, summary, description, deprecated.Machine-readable navigation index for tooling that needs a non-Swagger view of the URL surface (smoke-test scripts, auto-generated nav, etc.).

All endpoints across the pack carry @OpSwagger(ignore = true) on their handlers — they show up in the runtime URL space but are excluded from any Swagger / OpenAPI spec generated by the api-docs mixin pack. Ops endpoints are not API-meaningful, and shipping them in the spec leaks operator surface to consumers.

Composing the pack​

Each mixin is independent — drop in only the ones you need. The plan-A composition mounts all three with builder-driven configuration plus the deny-all override seam:

@Rest(
path = "/api",
mixins = {
EchoMixin.class,
AdminMixin.class,
RouteIndexMixin.class
}
)
public class ApiResource extends RestServlet {

@RestGet("/items") public List<Item> items() { ... }

// Required: register an auth guard chain. This REPLACES the AdminMixin
// DenyAllGuard default — the framework's bean-store override seam swaps the entire
// annotation-derived guard list for the user-supplied chain.
@Bean(name = "guards")
public RestGuardList guards(BeanStore bs) {
return RestGuardList.create(bs)
.append(new MyAuthGuard())
.build();
}

// Enable echo (default OFF) and, optionally, tighten the body cap or extend the redacted-header list.
@Bean public EchoMixin echo() {
return EchoMixin.create()
.enabled() // reachable regardless of logger level; omit to keep it 404
.bodyLimit(64 * 1024L)
.redactHeader("X-Internal-Trace")
.build();
}

// Optional: register cache-flush hooks.
@Bean public AdminMixin admin() {
return AdminMixin.create()
.cacheFlush("primary", () -> primaryCache.invalidateAll())
.cacheFlush("session", () -> sessionStore.purge())
.build();
}
}

The framework's mixin walk picks up each @Bean <MixinClass> factory before falling back to no-arg construction (see Mixin Sub-Contexts for the underlying lookup), so the host controls the configuration end-to-end without subclassing. A mixin without a @Bean factory gets default behavior: EchoMixin is disabled by default (404) and, once enabled, uses the 1 MB body cap and the standard redacted-header set; AdminMixin runs zero cache-flush hooks and the default thread-name-prefix exclude list; RouteIndexMixin has no configurable state.

Standalone deployment​

Each mixin is a fully-fledged @Rest-annotated resource and can also be subclassed and mounted as its own top-level servlet:

@Rest(paths = {"/echo/*"})
public class EchoResource extends EchoMixin {
// Enable echo (default OFF) — reachable regardless of logger level.
@Bean public EchoMixin echo() { return EchoMixin.create().enabled().build(); }
}

Both deployment styles (mixin into an existing servlet vs. mount as a sibling servlet) work the same way under Spring Boot and under the Jetty microservice — the EchoMixin_JettyMicroservice_Test and EchoMixin_Springboot_Test parity tests cover both paths end-to-end, including the secure-by-default sensitive-header redaction surviving the network stack.

Per-mixin notes​

EchoMixin​

Enablement (default OFF). The handler is disabled by default and returns 404 Not Found so the existence of the URL isn't disclosed. Reachability is an explicit, non-logging decision — logger level has no effect on it. Enablement is tri-state in EchoMixin.Builder: an explicit enabled(true)/enabled(false) wins; when unset, it resolves the ${juneau.echo.enabled:false} SVL fallback (system property or relaxed environment variable) per request:

@Bean public EchoMixin echo() {
return EchoMixin.create().enabled().build(); // reachable; independent of JUL level
}

Because the juneau.echo.enabled fallback is a JVM-global signal, a single property flips echo on for every resource that mixes EchoMixin in across the JVM — prefer per-host builder enabled() to bound the blast radius. Because every request to /echo/* then reflects the full payload back to anyone who can reach the URL, pair enablement with the host's inherited @Rest(guards=...) chain in production so only authorized operators can reach the endpoint at all. See Logging / Debugging for how logger levels now control verbosity only.

Sensitive-header redaction. Token-bearing headers MUST never be reflected back; that would defeat any auth scheme in front of the endpoint. The default redacted list is, case-insensitively, Authorization, Cookie, Set-Cookie, Proxy-Authorization, and X-API-Key. Each redacted header surfaces in the echo body with the literal value [REDACTED] so the caller can see the header was present without leaking its value. Override via Builder.redactedHeaders(String...) (replaces) or Builder.redactHeader(String) (additive).

Body capture and truncation. The handler reads up to Builder.bodyLimit(long) bytes (default 1 MB) of the inbound body and emits it as a UTF-8 string in the content field of the JSON response. When the body exceeds the cap, the captured portion is truncated and the truncated flag is set to true so callers can see the response is incomplete.

Response shape.

{
"method": "POST",
"path": "/echo/foo/bar",
"queryString": "x=1",
"pathRemainder": "foo/bar",
"headers": { "User-Agent": "curl", "Authorization": "[REDACTED]" },
"queryParams": { "x": "1" },
"attributes": { },
"contentLength": 5,
"content": "hello",
"truncated": false
}

AdminMixin​

Default-deny security posture. The mixin is annotated with @Rest(guards=DenyAllGuard.class), so every admin path returns 403 Forbidden until the host registers a @Bean RestGuardList factory. The framework's bean-store override seam replaces 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.).

Why deny-all rather than a placeholder role name? A "non-existent role" placeholder (e.g. roleGuard="ROLE_ADMIN_NONE_DEFAULT") is overridable but couples the host's auth strategy to a framework-internal role identifier. Replacing a RestGuardList via @Bean keeps the override surface narrow — one factory method on the host — and matches the way every other Juneau op-context bean is wired.

The integration with Juneau's AuthN guard pack (bearer-token guard, API-key guard, JWT verification) requires no change to this mixin: those guards plug into the same @Bean RestGuardList seam, so dropping in the canonical auth chain unlocks the admin paths automatically.

Endpoints.

  • GET /admin/threads — JSON list of currently-live threads. The default thread filter excludes framework noise (JVM internals, servlet container, Spring Boot infrastructure); override via Builder.threadNamePrefixExclude(String...). Each entry: name, id, state, daemon, priority, stack (frames as strings).

  • GET /admin/heap — JSON map: heap.{total,free,max,used} from Runtime, nonHeap.{init,used,committed,max} from MemoryMXBean.getNonHeapMemoryUsage(), plus availableProcessors. No heap-dump file generation in v1 (security risk; revisit when paired with an authenticated egress channel).

  • POST /admin/cache/flush — runs all registered cache-flush hooks, or just a comma-separated ?names=... subset. Hooks register name-keyed via Builder.cacheFlush(String, Runnable); callers that want async semantics own the threading model. Unknown names are silently ignored (404-on-unknown would leak the registered hook set).

  • GET /admin/ratelimit — JSON map keyed by bean name listing every registered RateLimitGuard. Each entry has two sub-fields: config (the guard's static configuration: class, limit, permitsPerSecond, xForwardedForAware, exemptPaths) and snapshot (a sorted-by-key array of BucketState entries describing every per-key bucket the storage backend currently tracks). Each BucketState carries key, tokens (fractional fill level), remaining (integer tokens available — mirrors the X-RateLimit-Remaining advisory header), throttled (convenience flag for tokens < 1.0), and lastRequest (wall-clock Instant of last activity). Returns 404 Not Found when no RateLimitGuard bean is registered. Storage backends that don't override Storage.snapshot() (e.g. Redis-backed impls that can't cheaply enumerate buckets) emit "snapshot": [] for their bean entry — the SPI method is default returning Map.of() so external Storage implementations stay backwards-compatible. See Rate-Limiting and Request-Id Propagation § Inspecting bucket state at runtime for the full snapshot SPI contract.

    Example response shape:

    {
    "guards": {
    "rateLimit": {
    "config": {
    "class": "org.apache.juneau.rest.server.guard.RateLimitGuard",
    "limit": 100,
    "permitsPerSecond": 1.67,
    "xForwardedForAware": false,
    "exemptPaths": ["/healthz", "/livez", "/readyz"]
    },
    "snapshot": [
    {"key": "203.0.113.42", "tokens": 23.0, "remaining": 23, "throttled": false, "lastRequest": "2026-05-26T14:00:42Z"},
    {"key": "203.0.113.99", "tokens": 0.5, "remaining": 0, "throttled": true, "lastRequest": "2026-05-26T14:00:58Z"}
    ]
    }
    }
    }

RouteIndexMixin​

Output. A JSON list of every @RestOp-annotated method on the host (and on every mixin on the host), ordered by path. Each entry has path, methods (single-element list of the HTTP method), summary, description, and deprecated. The two URLs (/options and /routes) return identical bodies — synonyms for caller convenience.

Excluded entries.

  • The route-index endpoint itself (it shouldn't echo its own listing).
  • Any operation marked @OpSwagger(ignore=true) — consistent with how those operations are excluded from the OpenAPI spec by 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.
  • Lifecycle / filter beans — only methods carrying a @RestOp-group annotation (@RestGet / @RestPost / @RestPut / @RestDelete / @RestPatch / @RestOptions / @RestOp) are listed.

The mixin has no configurable state — new RouteIndexMixin() is the only constructor, and the listing is computed off the host RestContext at request time.

Deployment notes​

MockRest​

All three mixins work with MockRestClient. Echo is disabled by default, so register a @Bean EchoMixin that calls .enabled() (logger level is irrelevant) and pair with MockRestClient.buildLax(...) to exercise the full echo. For the Admin mixin, register an empty @Bean RestGuardList to bypass the deny-all in unit tests:

@Rest(mixins = AdminMixin.class)
public static class TestHost extends RestServlet {
@Bean public RestGuardList guards(BeanStore bs) { return RestGuardList.create(bs).build(); }
}

Spring Boot​

Spring's bean-store adapter resolves both the per-mixin configuration @Beans (@Bean EchoMixin, @Bean AdminMixin) and the override @Bean RestGuardList / @Bean RateLimitGuard through the standard ApplicationContext.getBean(Class) path, so all override seams behave identically under Spring Boot. The Spring Boot parity test (EchoMixin_Springboot_Test) boots a full embedded Tomcat context to confirm the redaction guarantee survives the network stack and Spring's serialization wrapper.

Jetty microservice​

The bare-Jetty parity test (EchoMixin_JettyMicroservice_Test) boots a Microservice on an ephemeral port via MicroserviceTestFixture to assert the same end-to-end behavior (real HTTP, real Content-Type negotiation, real Authorization redaction) without Spring in the picture.

Migration​

There's no pre-existing version of this pack to migrate from — this is the first cut. If your service hand-rolled an echo / admin / routes endpoint before adopting the pack:

  1. Replace the hand-rolled /echo handler with mixins = EchoMixin.class, enable it explicitly via a @Bean EchoMixin that calls .enabled() (it is 404 by default and logger level does not affect it), and pair with a guard chain. Audit your old echo for sensitive-header leaks — the pre-pack hand-rolls almost always reflected Authorization and Cookie headers verbatim.
  2. Replace any hand-rolled JVM-introspection endpoints with mixins = AdminMixin.class plus a @Bean RestGuardList. The cache-flush hooks register via the builder rather than via per-handler subclassing.
  3. Replace any hand-rolled "list my endpoints" handler with mixins = RouteIndexMixin.class. The output shape is intentionally minimal; if you need the full Swagger/OpenAPI surface, add the api-docs mixin pack instead.

See also​