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 d4bdb2c190 feat: TODO-66 RateLimitGuard + RequestIdFilter
(juneau-rest-server)
d4bdb2c190 is described below
commit d4bdb2c190a6646fa67c874be2d70d05b9932c78
Author: James Bognar <[email protected]>
AuthorDate: Sat May 23 09:54:49 2026 -0400
feat: TODO-66 RateLimitGuard + RequestIdFilter (juneau-rest-server)
---
.../apache/juneau/rest/RestServerConstants.java | 14 +
.../apache/juneau/rest/filter/RequestIdFilter.java | 211 +++++++++
.../apache/juneau/rest/guard/RateLimitGuard.java | 527 +++++++++++++++++++++
.../rest/filter/RequestIdFilter_Echo_Test.java | 59 +++
.../rest/filter/RequestIdFilter_Honor_Test.java | 59 +++
.../filter/RequestIdFilter_Malformed_Test.java | 173 +++++++
.../juneau/rest/filter/RequestIdFilter_Test.java | 105 ++++
.../guard/RateLimitGuard_AdvisoryHeaders_Test.java | 87 ++++
.../rest/guard/RateLimitGuard_Eviction_Test.java | 114 +++++
.../guard/RateLimitGuard_ExemptPaths_Test.java | 122 +++++
.../guard/RateLimitGuard_KeyIsolation_Test.java | 93 ++++
.../juneau/rest/guard/RateLimitGuard_Test.java | 192 ++++++++
.../guard/RateLimitGuard_XForwardedFor_Test.java | 99 ++++
...md => FINISHED-66-rate-limit-and-request-id.md} | 0
todo/TODO-73-rest-paths-runtime-override.md | 153 ++++++
todo/TODO-74-mixin-api-docs.md | 137 ++++++
todo/TODO.md | 2 -
17 files changed, 2145 insertions(+), 2 deletions(-)
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java
index 103cd9f84e..55fcd35a9b 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java
@@ -212,4 +212,18 @@ public final class RestServerConstants {
/** The {@code "value"} annotation attribute name — used by {@code
@RestOp}/verb annotations to hold the (optional method-prefixed) path; folded
into {@link #PROPERTY_path}. */
public static final String PROPERTY_value = "value";
+
+ /**
+ * Servlet-request attribute key under which the per-request id minted
or honored by
+ * {@code org.apache.juneau.rest.filter.RequestIdFilter} is stashed.
+ *
+ * <p>
+ * Call loggers and observability layers should resolve the id via this
key so they all see the same value:
+ * <p class='bjava'>
+ * String <jv>id</jv> =
(String)<jv>req</jv>.getAttribute(RestServerConstants.<jsf>REQUEST_ID</jsf>);
+ * </p>
+ *
+ * @since 9.5.0
+ */
+ public static final String REQUEST_ID = "requestId";
}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/filter/RequestIdFilter.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/filter/RequestIdFilter.java
new file mode 100644
index 0000000000..1a404b601f
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/filter/RequestIdFilter.java
@@ -0,0 +1,211 @@
+/*
+ * 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.filter;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.util.*;
+import java.util.function.*;
+import java.util.regex.*;
+
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+
+import jakarta.servlet.http.*;
+
+/**
+ * Per-request id mint-or-honor filter that stashes the id on the servlet
request and echoes it on the response.
+ *
+ * <p>
+ * Designed to be invoked from a {@link RestStartCall @RestStartCall} method
so the id is available to every
+ * downstream component — the call logger, observability layers, and any
application code that reads
+ * {@code req.getAttribute(RestServerConstants.REQUEST_ID)}.
+ *
+ * <h5 class='topic'>Behavior</h5>
+ *
+ * <ul>
+ * <li>If the incoming request carries an {@code X-Request-Id} header and
the value matches the configured
+ * {@linkplain Builder#validator(Predicate) validator}, that value
is honored.
+ * <li>Otherwise (header absent, blank, or rejected by the validator), a
fresh id is minted via the configured
+ * {@linkplain Builder#idSupplier(Supplier) supplier} (default:
{@link UUID#randomUUID()}).
+ * <li>The chosen id is stashed on the underlying servlet request under the
+ * {@link org.apache.juneau.rest.RestServerConstants#REQUEST_ID
REQUEST_ID} attribute key.
+ * <li>The chosen id is echoed on the response as {@code X-Request-Id}.
+ * </ul>
+ *
+ * <h5 class='topic'>Default validator</h5>
+ *
+ * <p>
+ * The default validator is {@code ^[A-Za-z0-9-_]{1,128}$}, which accepts
UUIDs and the bulk of distributed-tracing
+ * id schemes (W3C Trace Context, OpenTelemetry, Datadog, etc.) while
rejecting whitespace, control characters,
+ * header-injection payloads, and oversized strings. Customize via {@link
Builder#validator(Predicate)}.
+ *
+ * <h5 class='topic'>Example usage</h5>
+ *
+ * <p class='bjava'>
+ * <ja>@Rest</ja>
+ * <jk>public class</jk> ApiResource <jk>extends</jk> BasicRestServlet {
+ *
+ * <jk>private static final</jk> RequestIdFilter
<jsf>REQUEST_ID</jsf> = RequestIdFilter.<jsm>create</jsm>().build();
+ *
+ * <ja>@RestStartCall</ja>
+ * <jk>public void</jk> stampRequestId(HttpServletRequest
<jv>req</jv>, HttpServletResponse <jv>res</jv>) {
+ * <jsf>REQUEST_ID</jsf>.apply(<jv>req</jv>, <jv>res</jv>);
+ * }
+ * }
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/RestServerRateLimitAndRequestId">REST
Server — Rate-Limiting and Request-Id Propagation</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public class RequestIdFilter {
+
+ /** Standard request and response header name for the request id. */
+ public static final String HEADER_REQUEST_ID = "X-Request-Id";
+
+ /** Default validator pattern. Matches UUIDs and most
distributed-tracing id schemes. */
+ public static final String DEFAULT_VALIDATOR_PATTERN =
"^[A-Za-z0-9-_]{1,128}$";
+
+ private final Supplier<String> idSupplier;
+ private final Predicate<String> validator;
+ private final String attributeKey;
+
+ /**
+ * Constructor.
+ *
+ * @param b The builder configuring this filter. Must not be
<jk>null</jk>.
+ */
+ protected RequestIdFilter(Builder b) {
+ assertArgNotNull("builder", b);
+ this.idSupplier = b.idSupplier != null ? b.idSupplier : () ->
UUID.randomUUID().toString();
+ this.validator = b.validator != null ? b.validator :
Pattern.compile(DEFAULT_VALIDATOR_PATTERN).asPredicate();
+ this.attributeKey = b.attributeKey;
+ }
+
+ /**
+ * Creates a new builder.
+ *
+ * @return A new builder.
+ */
+ public static Builder create() {
+ return new Builder();
+ }
+
+ /**
+ * Mints or honors a request id, stashes it on the request, and echoes
it on the response.
+ *
+ * <p>
+ * Idempotent: if the attribute is already set on the servlet request
(for example by a parent filter), the
+ * existing value is honored and re-echoed.
+ *
+ * @param req The servlet request. Must not be <jk>null</jk>.
+ * @param res The servlet response. Must not be <jk>null</jk>.
+ * @return The resolved request id. Never <jk>null</jk>.
+ */
+ public String apply(HttpServletRequest req, HttpServletResponse res) {
+ assertArgNotNull("req", req);
+ assertArgNotNull("res", res);
+ var existing = req.getAttribute(attributeKey);
+ if (existing instanceof String s && ! s.isEmpty()) {
+ res.setHeader(HEADER_REQUEST_ID, s);
+ return s;
+ }
+ var incoming = req.getHeader(HEADER_REQUEST_ID);
+ var id = (incoming != null && validator.test(incoming)) ?
incoming : idSupplier.get();
+ req.setAttribute(attributeKey, id);
+ res.setHeader(HEADER_REQUEST_ID, id);
+ return id;
+ }
+
+ /**
+ * Builder for {@link RequestIdFilter}.
+ */
+ public static class Builder {
+
+ Supplier<String> idSupplier;
+ Predicate<String> validator;
+ String attributeKey =
org.apache.juneau.rest.RestServerConstants.REQUEST_ID;
+
+ /**
+ * Constructor.
+ */
+ protected Builder() {}
+
+ /**
+ * Sets the supplier used to mint a new id when none is honored
from the request.
+ *
+ * <p>
+ * Default is {@link UUID#randomUUID()}. Swap in a smaller /
shorter id scheme (e.g. a 16-byte
+ * base32 token) when payload size matters.
+ *
+ * @param value The supplier. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public Builder idSupplier(Supplier<String> value) {
+ assertArgNotNull("value", value);
+ idSupplier = value;
+ return this;
+ }
+
+ /**
+ * Sets the predicate used to validate an incoming {@code
X-Request-Id} header.
+ *
+ * <p>
+ * Default is {@code
Pattern.compile("^[A-Za-z0-9-_]{1,128}$").asPredicate()} — accepts UUIDs and
most
+ * distributed-tracing id schemes while rejecting whitespace,
control characters, and oversize values.
+ * Values that fail validation are discarded and a fresh id is
minted.
+ *
+ * @param value The predicate. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public Builder validator(Predicate<String> value) {
+ assertArgNotNull("value", value);
+ validator = value;
+ return this;
+ }
+
+ /**
+ * Overrides the servlet-request attribute key under which the
id is stashed.
+ *
+ * <p>
+ * Defaults to {@link
org.apache.juneau.rest.RestServerConstants#REQUEST_ID}. Override only when
+ * coexisting with a third-party filter that publishes the id
under a different key.
+ *
+ * @param value The attribute key. Must not be <jk>null</jk>
or blank.
+ * @return This object.
+ */
+ public Builder attributeKey(String value) {
+ assertArgNotNull("value", value);
+ if (value.isBlank())
+ throw new IllegalArgumentException("Argument
'value' must not be blank.");
+ attributeKey = value;
+ return this;
+ }
+
+ /**
+ * Builds the filter.
+ *
+ * @return A new {@link RequestIdFilter}.
+ */
+ public RequestIdFilter build() {
+ return new RequestIdFilter(this);
+ }
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/guard/RateLimitGuard.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/guard/RateLimitGuard.java
new file mode 100644
index 0000000000..7e4ca1b888
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/guard/RateLimitGuard.java
@@ -0,0 +1,527 @@
+/*
+ * 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 static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.time.*;
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.function.*;
+
+import org.apache.juneau.http.response.*;
+import org.apache.juneau.rest.*;
+
+/**
+ * Token-bucket {@link RestGuard} that throttles requests on a configurable
per-request key.
+ *
+ * <p>
+ * On each request the guard atomically attempts to acquire one token from a
per-key bucket. When tokens are
+ * available the request proceeds and three advisory response headers are
populated
+ * ({@code X-RateLimit-Limit}, {@code X-RateLimit-Remaining}, {@code
X-RateLimit-Reset}). When the bucket is
+ * empty a {@link TooManyRequests} exception is thrown with a {@code
Retry-After} header set to the integer
+ * number of seconds until the next token will be available.
+ *
+ * <h5 class='topic'>Example usage</h5>
+ *
+ * <p class='bjava'>
+ * <ja>@Rest</ja>(path=<js>"/api"</js>)
+ * <jk>public class</jk> ApiResource <jk>extends</jk> BasicRestServlet {
+ *
+ * <jc>// Per-IP rate limit, 100 req/min, 200-burst.</jc>
+ * <ja>@Bean</ja>(name=<js>"guards"</js>)
+ * <jk>public</jk> RestGuardList rateLimits(BeanStore <jv>bs</jv>)
{
+ * <jk>return</jk>
RestGuardList.<jsm>create</jsm>(<jv>bs</jv>)
+ * .append(RateLimitGuard.<jsm>create</jsm>()
+ * .permitsPerMinute(100)
+ * .burst(200)
+ * .build())
+ * .build();
+ * }
+ * }
+ * </p>
+ *
+ * <h5 class='topic'>Keying</h5>
+ *
+ * <p>
+ * The default key is the request's {@linkplain RestRequest#getRemoteAddr()
remote address}. Set
+ * {@link Builder#xForwardedForAware(boolean) xForwardedForAware(true)} to
honor the first hop of the
+ * {@code X-Forwarded-For} header — required when running behind a trusted
reverse proxy.
+ *
+ * <p>
+ * <b>Trusted-proxy assumption.</b> When {@code xForwardedForAware} is
enabled without a trusted proxy in front
+ * of the application, attackers can spoof the {@code X-Forwarded-For} header
and defeat the IP-based key. Only
+ * opt in when the request edge enforces the header.
+ *
+ * <h5 class='topic'>Storage</h5>
+ *
+ * <p>
+ * Bucket state is held by a pluggable {@link Storage} SPI. The default
{@link Storage#inMemory()} implementation
+ * uses a {@link ConcurrentHashMap} with an LRU-style eviction policy (capped
at 100 000 keys by default), suitable
+ * for single-pod deployments. Distributed deployments should provide a
substrate-backed implementation
+ * (e.g. Redis) so per-pod buckets do not split the shared rate envelope.
+ *
+ * <h5 class='topic'>Probe paths</h5>
+ *
+ * <p>
+ * Built-in probe paths ({@code /healthz}, {@code /readyz}, {@code /livez})
are exempted from throttling by
+ * default so a misconfigured limit cannot mask a healthy pod from the
orchestrator. Override via
+ * {@link Builder#exemptPaths(String...)}.
+ *
+ * <h5 class='topic'>Time source</h5>
+ *
+ * <p>
+ * Refill math uses {@link System#nanoTime()}. This is monotonic and safe
across wall-clock jumps but does not
+ * map to a calendar instant, which slightly complicates debugging when the
bucket is in an unexpected state.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/RestServerRateLimitAndRequestId">REST
Server — Rate-Limiting and Request-Id Propagation</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public class RateLimitGuard extends RestGuard {
+
+ /** Response header name for the advisory ceiling (tokens per refill
window). */
+ public static final String HEADER_LIMIT = "X-RateLimit-Limit";
+
+ /** Response header name for the advisory tokens remaining after this
call. */
+ public static final String HEADER_REMAINING = "X-RateLimit-Remaining";
+
+ /** Response header name for the advisory seconds until the bucket is
fully refilled. */
+ public static final String HEADER_RESET = "X-RateLimit-Reset";
+
+ /** Standard HTTP {@code Retry-After} header name. */
+ public static final String HEADER_RETRY_AFTER = "Retry-After";
+
+ private final int capacity;
+ private final double permitsPerSecond;
+ private final Function<RestRequest,String> keyResolver;
+ private final boolean xForwardedForAware;
+ private final Set<String> exemptPaths;
+ private final BiConsumer<RestRequest,RateLimitInfo> onLimitExceeded;
+ private final Storage storage;
+
+ /**
+ * Constructor.
+ *
+ * @param b The builder configuring this guard. Must not be
<jk>null</jk>.
+ */
+ protected RateLimitGuard(Builder b) {
+ assertArgNotNull("builder", b);
+ if (b.permitsPerSecond <= 0.0)
+ throw new IllegalArgumentException("Argument
'permitsPerSecond' must be > 0.");
+ if (b.burst <= 0)
+ throw new IllegalArgumentException("Argument 'burst'
must be > 0.");
+ this.capacity = b.burst;
+ this.permitsPerSecond = b.permitsPerSecond;
+ this.keyResolver = b.keyResolver != null ? b.keyResolver :
RestRequest::getRemoteAddr;
+ this.xForwardedForAware = b.xForwardedForAware;
+ this.exemptPaths = Set.copyOf(b.exemptPaths);
+ this.onLimitExceeded = b.onLimitExceeded;
+ this.storage = b.storage != null ? b.storage :
Storage.inMemory();
+ }
+
+ /**
+ * Creates a new builder.
+ *
+ * @return A new builder.
+ */
+ public static Builder create() {
+ return new Builder();
+ }
+
+ @Override /* Overridden from RestGuard */
+ public boolean guard(RestRequest req, RestResponse res) {
+ if (isExempt(req))
+ return true;
+ var key = resolveKey(req);
+ var result = storage.tryAcquire(key, capacity,
permitsPerSecond);
+ var info = new RateLimitInfo(key, capacity, result.remaining(),
result.secondsUntilReset(), result.allowed());
+ if (! result.allowed()) {
+ setAdvisoryHeaders(res, info);
+ var retry = Math.max(1L, info.secondsUntilReset());
+ res.setHeader(HEADER_RETRY_AFTER, Long.toString(retry));
+ if (onLimitExceeded != null)
+ onLimitExceeded.accept(req, info);
+ throw tooManyRequests(info);
+ }
+ setAdvisoryHeaders(res, info);
+ return true;
+ }
+
+ @Override /* Overridden from RestGuard */
+ public boolean isRequestAllowed(RestRequest req) {
+ return true;
+ }
+
+ private boolean isExempt(RestRequest req) {
+ if (exemptPaths.isEmpty())
+ return false;
+ var pi = Objects.toString(req.getPathInfo(), "");
+ var sp = Objects.toString(req.getServletPath(), "");
+ return exemptPaths.contains(pi) || exemptPaths.contains(sp);
+ }
+
+ private String resolveKey(RestRequest req) {
+ if (xForwardedForAware) {
+ var xff = req.getHeader("X-Forwarded-For");
+ if (xff != null && ! xff.isBlank()) {
+ var comma = xff.indexOf(',');
+ return (comma < 0 ? xff : xff.substring(0,
comma)).trim();
+ }
+ }
+ var k = keyResolver.apply(req);
+ return k != null ? k : "";
+ }
+
+ private static TooManyRequests tooManyRequests(RateLimitInfo info) {
+ var retry = Math.max(1L, info.secondsUntilReset());
+ var ex = new TooManyRequests("Rate limit exceeded for key
''{0}''. Retry after {1}s.", info.key(), retry);
+ ex.setHeader(HEADER_LIMIT, Integer.toString(info.limit()));
+ ex.setHeader(HEADER_REMAINING, "0");
+ ex.setHeader(HEADER_RESET,
Long.toString(info.secondsUntilReset()));
+ ex.setHeader(HEADER_RETRY_AFTER, Long.toString(retry));
+ return ex;
+ }
+
+ private static void setAdvisoryHeaders(RestResponse res, RateLimitInfo
info) {
+ res.setHeader(HEADER_LIMIT, Integer.toString(info.limit()));
+ res.setHeader(HEADER_REMAINING,
Integer.toString(info.remaining()));
+ res.setHeader(HEADER_RESET,
Long.toString(info.secondsUntilReset()));
+ }
+
+ /**
+ * Builder for {@link RateLimitGuard}.
+ */
+ public static class Builder {
+
+ private static final Set<String> DEFAULT_EXEMPT_PATHS =
Set.of("/healthz", "/readyz", "/livez");
+
+ double permitsPerSecond = 10.0;
+ int burst = 10;
+ Function<RestRequest,String> keyResolver;
+ boolean xForwardedForAware;
+ Set<String> exemptPaths = new
LinkedHashSet<>(DEFAULT_EXEMPT_PATHS);
+ BiConsumer<RestRequest,RateLimitInfo> onLimitExceeded;
+ Storage storage;
+
+ /**
+ * Constructor.
+ */
+ protected Builder() {}
+
+ /**
+ * Sets the steady-state refill rate in permits per second.
+ *
+ * @param value Permits per second. Must be {@code > 0}.
+ * @return This object.
+ */
+ public Builder permitsPerSecond(int value) {
+ permitsPerSecond = value;
+ return this;
+ }
+
+ /**
+ * Sets the steady-state refill rate in permits per minute.
+ *
+ * <p>
+ * Equivalent to {@code permitsPerSecond(value / 60.0)}.
+ *
+ * @param value Permits per minute. Must be {@code > 0}.
+ * @return This object.
+ */
+ public Builder permitsPerMinute(int value) {
+ permitsPerSecond = value / 60.0;
+ return this;
+ }
+
+ /**
+ * Sets the steady-state refill rate in permits per hour.
+ *
+ * <p>
+ * Equivalent to {@code permitsPerSecond(value / 3600.0)}.
+ *
+ * @param value Permits per hour. Must be {@code > 0}.
+ * @return This object.
+ */
+ public Builder permitsPerHour(int value) {
+ permitsPerSecond = value / 3600.0;
+ return this;
+ }
+
+ /**
+ * Sets the burst capacity (maximum tokens a bucket can hold).
+ *
+ * @param value Burst capacity. Must be {@code > 0}.
+ * @return This object.
+ */
+ public Builder burst(int value) {
+ burst = value;
+ return this;
+ }
+
+ /**
+ * Sets the per-request key resolver.
+ *
+ * <p>
+ * Default is {@link RestRequest#getRemoteAddr()}. Common
alternatives include the user-principal name,
+ * an API-key header, or a tenant id. When the resolver
returns <jk>null</jk> the empty string is used.
+ *
+ * @param value Resolver function. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public Builder keyBy(Function<RestRequest,String> value) {
+ assertArgNotNull("value", value);
+ keyResolver = value;
+ return this;
+ }
+
+ /**
+ * Enables {@code X-Forwarded-For}-aware key resolution.
+ *
+ * <p>
+ * When enabled, the first comma-separated hop of the {@code
X-Forwarded-For} header takes precedence over
+ * the configured {@link #keyBy(Function)} resolver. Only opt
in when a trusted reverse proxy strips and
+ * rewrites the header; otherwise clients can spoof it to
defeat IP-based throttling.
+ *
+ * @param value <jk>true</jk> to honor {@code X-Forwarded-For}.
+ * @return This object.
+ */
+ public Builder xForwardedForAware(boolean value) {
+ xForwardedForAware = value;
+ return this;
+ }
+
+ /**
+ * Sets the set of request paths that bypass throttling.
+ *
+ * <p>
+ * Matched against {@link RestRequest#getPathInfo()} and {@link
RestRequest#getServletPath()} (exact
+ * match). Defaults to {@code /healthz}, {@code /readyz},
{@code /livez} — the built-in probe paths.
+ * Pass an empty array to clear the defaults.
+ *
+ * @param values Exempt paths. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public Builder exemptPaths(String...values) {
+ assertArgNotNull("values", values);
+ exemptPaths = new
LinkedHashSet<>(Arrays.asList(values));
+ return this;
+ }
+
+ /**
+ * Sets a callback invoked when a request is rejected due to an
empty bucket.
+ *
+ * <p>
+ * Useful for structured logging or metrics emission. The
callback runs before {@link TooManyRequests} is
+ * thrown and must not block. Exceptions raised inside the
callback propagate to the caller.
+ *
+ * @param value Callback. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public Builder
whenLimitExceeded(BiConsumer<RestRequest,RateLimitInfo> value) {
+ assertArgNotNull("value", value);
+ onLimitExceeded = value;
+ return this;
+ }
+
+ /**
+ * Sets the bucket-state storage backend.
+ *
+ * <p>
+ * Default is {@link Storage#inMemory()}. Substitute a
distributed backend (Redis, DynamoDB, etc.) when
+ * running multiple pods that share a single rate envelope.
+ *
+ * @param value Storage backend. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public Builder storage(Storage value) {
+ assertArgNotNull("value", value);
+ storage = value;
+ return this;
+ }
+
+ /**
+ * Builds the guard.
+ *
+ * @return A new {@link RateLimitGuard}.
+ */
+ public RateLimitGuard build() {
+ return new RateLimitGuard(this);
+ }
+ }
+
+ /**
+ * Snapshot of rate-limit state passed to the {@link
Builder#whenLimitExceeded(BiConsumer) onLimitExceeded}
+ * callback and used to populate advisory response headers.
+ *
+ * @param key The per-request key (e.g. remote address, principal name).
+ * @param limit The bucket capacity (also reported as {@code
X-RateLimit-Limit}).
+ * @param remaining Tokens remaining after this acquisition attempt.
Zero when the request was rejected.
+ * @param secondsUntilReset Seconds until the bucket is full again
(also reported as {@code X-RateLimit-Reset}).
+ * @param allowed <jk>true</jk> if the request was admitted;
<jk>false</jk> if the bucket was empty.
+ */
+ public record RateLimitInfo(String key, int limit, int remaining, long
secondsUntilReset, boolean allowed) {}
+
+ /**
+ * SPI for storing per-key token-bucket state.
+ *
+ * <p>
+ * Implementations must be thread-safe. The default {@link
#inMemory()} implementation is suitable for
+ * single-pod deployments. Multi-pod deployments that share a single
rate envelope should substitute a
+ * distributed backend.
+ *
+ * @since 9.5.0
+ */
+ public interface Storage {
+
+ /**
+ * Attempts to acquire one token from the bucket associated
with the given key.
+ *
+ * @param key The bucket key. Never <jk>null</jk>.
+ * @param capacity The maximum tokens the bucket can hold.
+ * @param permitsPerSecond The steady-state refill rate.
+ * @return The outcome of the attempt. Never <jk>null</jk>.
+ */
+ AcquireResult tryAcquire(String key, int capacity, double
permitsPerSecond);
+
+ /**
+ * Evicts buckets idle for longer than the given TTL.
+ *
+ * <p>
+ * The default {@link #inMemory()} implementation evicts
opportunistically when its size cap is exceeded;
+ * call this method to force an eager sweep (typically from a
scheduled task).
+ *
+ * @param ttl The idle threshold. Buckets last touched longer
than {@code ttl} ago are removed.
+ */
+ void evict(Duration ttl);
+
+ /**
+ * Creates a new in-memory storage backend with the default
size cap (100 000 keys).
+ *
+ * @return A new in-memory storage backend.
+ */
+ static Storage inMemory() {
+ return new InMemoryStorage(100_000);
+ }
+
+ /**
+ * Creates a new in-memory storage backend with a custom size
cap.
+ *
+ * @param maxKeys The maximum number of keys held before
LRU-style eviction kicks in. Must be {@code > 0}.
+ * @return A new in-memory storage backend.
+ */
+ static Storage inMemory(int maxKeys) {
+ return new InMemoryStorage(maxKeys);
+ }
+
+ /**
+ * Outcome of a {@link #tryAcquire(String, int, double)
tryAcquire} attempt.
+ *
+ * @param allowed <jk>true</jk> if the request was admitted.
+ * @param remaining Tokens left in the bucket after the attempt.
+ * @param secondsUntilReset Seconds until the bucket is full
again.
+ */
+ record AcquireResult(boolean allowed, int remaining, long
secondsUntilReset) {}
+ }
+
+ /**
+ * Default in-memory {@link Storage} implementation.
+ *
+ * <p>
+ * Backed by a {@link ConcurrentHashMap} keyed by the per-request key.
Each bucket tracks token count and last
+ * touch time. When the map exceeds {@code maxKeys} entries the
least-recently-touched bucket is removed.
+ */
+ static final class InMemoryStorage implements Storage {
+
+ private final ConcurrentHashMap<String,Bucket> buckets = new
ConcurrentHashMap<>();
+ private final int maxKeys;
+
+ InMemoryStorage(int maxKeys) {
+ if (maxKeys <= 0)
+ throw new IllegalArgumentException("Argument
'maxKeys' must be > 0.");
+ this.maxKeys = maxKeys;
+ }
+
+ @Override
+ public AcquireResult tryAcquire(String key, int capacity,
double permitsPerSecond) {
+ var bucket = buckets.computeIfAbsent(key, k -> new
Bucket(capacity));
+ var result = bucket.tryAcquire(capacity,
permitsPerSecond);
+ if (buckets.size() > maxKeys)
+ evictOldest();
+ return result;
+ }
+
+ @Override
+ public void evict(Duration ttl) {
+ var threshold = System.nanoTime() - ttl.toNanos();
+ buckets.entrySet().removeIf(e ->
e.getValue().lastTouchedNanos() < threshold);
+ }
+
+ int size() {
+ return buckets.size();
+ }
+
+ private void evictOldest() {
+ buckets.entrySet().stream()
+ .min(Comparator.comparingLong(e ->
e.getValue().lastTouchedNanos()))
+ .map(Map.Entry::getKey)
+ .ifPresent(buckets::remove);
+ }
+ }
+
+ /**
+ * Per-key token-bucket state.
+ *
+ * <p>
+ * Holds a fractional token count and the {@link System#nanoTime()}
value of the last touch. The
+ * {@code synchronized} block keeps the read-refill-write sequence
atomic without the overhead of a lock-free
+ * CAS loop on this hot path.
+ */
+ static final class Bucket {
+
+ private double tokens;
+ private long lastNanos;
+
+ Bucket(int capacity) {
+ this.tokens = capacity;
+ this.lastNanos = System.nanoTime();
+ }
+
+ synchronized Storage.AcquireResult tryAcquire(int capacity,
double permitsPerSecond) {
+ var now = System.nanoTime();
+ var elapsedSeconds = (now - lastNanos) /
1_000_000_000.0;
+ tokens = Math.min(capacity, tokens + elapsedSeconds *
permitsPerSecond);
+ lastNanos = now;
+ if (tokens >= 1.0) {
+ tokens -= 1.0;
+ return new Storage.AcquireResult(true, (int)
Math.floor(tokens), secondsUntilFull(capacity, permitsPerSecond));
+ }
+ return new Storage.AcquireResult(false, 0,
secondsUntilFull(capacity, permitsPerSecond));
+ }
+
+ synchronized long lastTouchedNanos() {
+ return lastNanos;
+ }
+
+ private long secondsUntilFull(int capacity, double
permitsPerSecond) {
+ var needed = Math.max(0.0, capacity - tokens);
+ return (long) Math.ceil(needed / permitsPerSecond);
+ }
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/filter/RequestIdFilter_Echo_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/filter/RequestIdFilter_Echo_Test.java
new file mode 100644
index 0000000000..76b30a891e
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/filter/RequestIdFilter_Echo_Test.java
@@ -0,0 +1,59 @@
+/*
+ * 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.filter;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+import jakarta.servlet.http.*;
+
+class RequestIdFilter_Echo_Test extends TestBase {
+
+ @Rest
+ public static class A extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ private static final RequestIdFilter FILTER =
RequestIdFilter.create().build();
+ @RestStartCall
+ public void stamp(HttpServletRequest req, HttpServletResponse
res) {
+ FILTER.apply(req, res);
+ }
+ @RestGet(path="/a")
+ public String a(RestRequest req) {
+ return
req.getAttribute(RestServerConstants.REQUEST_ID).asString().orElse("");
+ }
+ }
+
+ @Test void a01_responseHeaderMatchesRequestAttribute() throws Exception
{
+ var c =
MockRestClient.create(A.class).ignoreErrors().json().build();
+ var res = c.get("/a").run().assertStatus(200);
+ var echoed =
res.getHeader("X-Request-Id").asString().orElseThrow();
+ res.assertContent().asString().isContains(echoed);
+ }
+
+ @Test void a02_eachRequestGetsIndependentId() throws Exception {
+ var c =
MockRestClient.create(A.class).ignoreErrors().json().build();
+ var id1 =
c.get("/a").run().assertStatus(200).getHeader("X-Request-Id").asString().orElseThrow();
+ var id2 =
c.get("/a").run().assertStatus(200).getHeader("X-Request-Id").asString().orElseThrow();
+ assertNotEquals(id1, id2);
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/filter/RequestIdFilter_Honor_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/filter/RequestIdFilter_Honor_Test.java
new file mode 100644
index 0000000000..3911e1901f
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/filter/RequestIdFilter_Honor_Test.java
@@ -0,0 +1,59 @@
+/*
+ * 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.filter;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+import jakarta.servlet.http.*;
+
+class RequestIdFilter_Honor_Test extends TestBase {
+
+ @Rest
+ public static class A extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ private static final RequestIdFilter FILTER =
RequestIdFilter.create().build();
+ @RestStartCall
+ public void stamp(HttpServletRequest req, HttpServletResponse
res) {
+ FILTER.apply(req, res);
+ }
+ @RestGet(path="/a")
+ public String a(RestRequest req) {
+ return
req.getAttribute(RestServerConstants.REQUEST_ID).asString().orElse("");
+ }
+ }
+
+ @Test void a01_honorsValidIncomingId() throws Exception {
+ var c =
MockRestClient.create(A.class).ignoreErrors().json().build();
+ c.get("/a").header("X-Request-Id",
"550e8400-e29b-41d4-a716-446655440000").run()
+ .assertStatus(200)
+
.assertHeader("X-Request-Id").is("550e8400-e29b-41d4-a716-446655440000")
+
.assertContent().asString().isContains("550e8400-e29b-41d4-a716-446655440000");
+ }
+
+ @Test void a02_honorsValidShortId() throws Exception {
+ var c =
MockRestClient.create(A.class).ignoreErrors().json().build();
+ c.get("/a").header("X-Request-Id", "abc123_-").run()
+ .assertStatus(200)
+ .assertHeader("X-Request-Id").is("abc123_-")
+ .assertContent().asString().isContains("abc123_-");
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/filter/RequestIdFilter_Malformed_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/filter/RequestIdFilter_Malformed_Test.java
new file mode 100644
index 0000000000..69ef991670
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/filter/RequestIdFilter_Malformed_Test.java
@@ -0,0 +1,173 @@
+/*
+ * 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.filter;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+import jakarta.servlet.http.*;
+
+class RequestIdFilter_Malformed_Test extends TestBase {
+
+ @Rest
+ public static class A extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ private static final RequestIdFilter FILTER =
RequestIdFilter.create().build();
+ @RestStartCall
+ public void stamp(HttpServletRequest req, HttpServletResponse
res) {
+ FILTER.apply(req, res);
+ }
+ @RestGet(path="/a")
+ public String a(RestRequest req) {
+ return
req.getAttribute(RestServerConstants.REQUEST_ID).asString().orElse("");
+ }
+ }
+
+ @Test void a01_malformedIdWithSpaceIsRejectedAndReminted() throws
Exception {
+ var c =
MockRestClient.create(A.class).ignoreErrors().json().build();
+ var res = c.get("/a").header("X-Request-Id", "abc
xyz").run().assertStatus(200);
+ var echoed =
res.getHeader("X-Request-Id").asString().orElseThrow();
+ assertNotEquals("abc xyz", echoed);
+ res.assertContent().asString().isContains(echoed);
+ }
+
+ @Test void a02_oversizeIdIsRejectedAndReminted() throws Exception {
+ var c =
MockRestClient.create(A.class).ignoreErrors().json().build();
+ var oversize = "a".repeat(200);
+ var res = c.get("/a").header("X-Request-Id",
oversize).run().assertStatus(200);
+ var echoed =
res.getHeader("X-Request-Id").asString().orElseThrow();
+ assertNotEquals(oversize, echoed);
+ }
+
+ @Test void a03_emptyIdIsRejectedAndReminted() throws Exception {
+ var c =
MockRestClient.create(A.class).ignoreErrors().json().build();
+ var res = c.get("/a").header("X-Request-Id",
"").run().assertStatus(200);
+ var echoed =
res.getHeader("X-Request-Id").asString().orElseThrow();
+ assertFalse(echoed.isEmpty());
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Custom validator rejects everything → always mints.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest
+ public static class B extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ private static final RequestIdFilter FILTER =
RequestIdFilter.create()
+ .validator(s -> false)
+ .idSupplier(() -> "always-minted")
+ .build();
+ @RestStartCall
+ public void stamp(HttpServletRequest req, HttpServletResponse
res) {
+ FILTER.apply(req, res);
+ }
+ @RestGet(path="/b")
+ public String b() { return "ok"; }
+ }
+
+ @Test void b01_customValidatorAlwaysRejectingAlwaysMints() throws
Exception {
+ var c = MockRestClient.buildLax(B.class);
+ c.get("/b").header("X-Request-Id",
"550e8400-e29b-41d4-a716-446655440000").run()
+ .assertStatus(200)
+ .assertHeader("X-Request-Id").is("always-minted");
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Custom attribute key is honored and observed in re-entry (existing
attribute is reused).
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest
+ public static class C extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ private static final RequestIdFilter FILTER =
RequestIdFilter.create()
+ .attributeKey("customReqId")
+ .idSupplier(() -> "minted-once")
+ .build();
+ @RestStartCall
+ public void stamp(HttpServletRequest req, HttpServletResponse
res) {
+ FILTER.apply(req, res);
+ FILTER.apply(req, res);
+ }
+ @RestGet(path="/c")
+ public String c() { return "ok"; }
+ }
+
+ @Test void c01_reentryHonorsExistingAttribute() throws Exception {
+ var c = MockRestClient.buildLax(C.class);
+ c.get("/c").run()
+ .assertStatus(200)
+ .assertHeader("X-Request-Id").is("minted-once");
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // An empty-string attribute already on the request is treated as
missing and reminted.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest
+ public static class D extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ private static final RequestIdFilter FILTER =
RequestIdFilter.create()
+ .idSupplier(() -> "fresh-after-empty")
+ .build();
+ @RestStartCall
+ public void stamp(HttpServletRequest req, HttpServletResponse
res) {
+ req.setAttribute(RestServerConstants.REQUEST_ID, "");
+ FILTER.apply(req, res);
+ }
+ @RestGet(path="/d")
+ public String d() { return "ok"; }
+ }
+
+ @Test void d01_emptyStringAttributeRemints() throws Exception {
+ var c = MockRestClient.buildLax(D.class);
+ c.get("/d").run()
+ .assertStatus(200)
+ .assertHeader("X-Request-Id").is("fresh-after-empty");
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // A non-String attribute already on the request is treated as missing
and reminted.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest
+ public static class E extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ private static final RequestIdFilter FILTER =
RequestIdFilter.create()
+ .idSupplier(() -> "fresh-after-non-string")
+ .build();
+ @RestStartCall
+ public void stamp(HttpServletRequest req, HttpServletResponse
res) {
+ req.setAttribute(RestServerConstants.REQUEST_ID,
Integer.valueOf(42));
+ FILTER.apply(req, res);
+ }
+ @RestGet(path="/e")
+ public String e() { return "ok"; }
+ }
+
+ @Test void e01_nonStringAttributeRemints() throws Exception {
+ var c = MockRestClient.buildLax(E.class);
+ c.get("/e").run()
+ .assertStatus(200)
+
.assertHeader("X-Request-Id").is("fresh-after-non-string");
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/filter/RequestIdFilter_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/filter/RequestIdFilter_Test.java
new file mode 100644
index 0000000000..5ed8683133
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/filter/RequestIdFilter_Test.java
@@ -0,0 +1,105 @@
+/*
+ * 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.filter;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+import jakarta.servlet.http.*;
+
+class RequestIdFilter_Test extends TestBase {
+
+ @Rest
+ public static class A extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ private static final RequestIdFilter FILTER =
RequestIdFilter.create().build();
+ @RestStartCall
+ public void stamp(HttpServletRequest req, HttpServletResponse
res) {
+ FILTER.apply(req, res);
+ }
+ @RestGet(path="/a")
+ public String a(RestRequest req) {
+ return
req.getAttribute(RestServerConstants.REQUEST_ID).asString().orElse("");
+ }
+ }
+
+ @Test void a01_mintsRequestIdWhenAbsent() throws Exception {
+ var c = MockRestClient.buildLax(A.class);
+ c.get("/a").run()
+ .assertStatus(200)
+ .assertHeader("X-Request-Id").isExists();
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Builder rejects null arguments.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_builderRejectsNullSupplier() {
+ assertThrows(IllegalArgumentException.class, () ->
RequestIdFilter.create().idSupplier(null));
+ }
+
+ @Test void b02_builderRejectsNullValidator() {
+ assertThrows(IllegalArgumentException.class, () ->
RequestIdFilter.create().validator(null));
+ }
+
+ @Test void b03_builderRejectsNullAttributeKey() {
+ assertThrows(IllegalArgumentException.class, () ->
RequestIdFilter.create().attributeKey(null));
+ }
+
+ @Test void b04_builderRejectsBlankAttributeKey() {
+ assertThrows(IllegalArgumentException.class, () ->
RequestIdFilter.create().attributeKey(" "));
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // apply() rejects null arguments.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_applyRejectsNullRequest() {
+ assertThrows(IllegalArgumentException.class, () ->
RequestIdFilter.create().build().apply(null, null));
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Custom supplier is honored when minting.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest
+ public static class D extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ private static final RequestIdFilter FILTER =
RequestIdFilter.create()
+ .idSupplier(() -> "fixed-id-123")
+ .build();
+ @RestStartCall
+ public void stamp(HttpServletRequest req, HttpServletResponse
res) {
+ FILTER.apply(req, res);
+ }
+ @RestGet(path="/d")
+ public String d() { return "ok"; }
+ }
+
+ @Test void d01_customSupplierUsedToMintId() throws Exception {
+ var c = MockRestClient.buildLax(D.class);
+ c.get("/d").run()
+ .assertStatus(200)
+ .assertHeader("X-Request-Id").is("fixed-id-123");
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_AdvisoryHeaders_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_AdvisoryHeaders_Test.java
new file mode 100644
index 0000000000..0b4511ccd3
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_AdvisoryHeaders_Test.java
@@ -0,0 +1,87 @@
+/*
+ * 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.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+class RateLimitGuard_AdvisoryHeaders_Test extends TestBase {
+
+ @Rest
+ public static class A extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @Bean public RestGuardList guards(BeanStore bs) {
+ return RestGuardList.create(bs).append(
+ RateLimitGuard.create()
+ .permitsPerSecond(1)
+ .burst(5)
+ .keyBy(req -> "static-headers-success")
+ .exemptPaths()
+ .build()
+ ).build();
+ }
+ @RestGet(path="/a")
+ public String a() { return "ok"; }
+ }
+
+ @Test void a01_advisoryHeadersPopulatedOnSuccess() throws Exception {
+ var c = MockRestClient.buildLax(A.class);
+ c.get("/a").run()
+ .assertStatus(200)
+ .assertHeader("X-RateLimit-Limit").is("5")
+ .assertHeader("X-RateLimit-Remaining").isExists()
+ .assertHeader("X-RateLimit-Reset").isExists();
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Separate resource (and therefore separate RestContext + storage) for
the rejection path
+ // so the per-class shared MockRestClient context cache doesn't leak
state between tests.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest
+ public static class B extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @Bean public RestGuardList guards(BeanStore bs) {
+ return RestGuardList.create(bs).append(
+ RateLimitGuard.create()
+ .permitsPerSecond(1)
+ .burst(5)
+ .keyBy(req ->
"static-headers-rejection")
+ .exemptPaths()
+ .build()
+ ).build();
+ }
+ @RestGet(path="/b")
+ public String b() { return "ok"; }
+ }
+
+ @Test void b01_advisoryHeadersOnRejection() throws Exception {
+ var c = MockRestClient.buildLax(B.class);
+ for (var i = 0; i < 5; i++)
+ c.get("/b").run().assertStatus(200);
+ c.get("/b").run()
+ .assertStatus(429)
+ .assertHeader("X-RateLimit-Limit").is("5")
+ .assertHeader("X-RateLimit-Remaining").is("0")
+ .assertHeader("X-RateLimit-Reset").isExists()
+ .assertHeader("Retry-After").asInteger().isExists();
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_Eviction_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_Eviction_Test.java
new file mode 100644
index 0000000000..fc8ca3d58e
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_Eviction_Test.java
@@ -0,0 +1,114 @@
+/*
+ * 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 static org.junit.jupiter.api.Assertions.*;
+
+import java.time.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+class RateLimitGuard_Eviction_Test extends TestBase {
+
+
//------------------------------------------------------------------------------------------------------------------
+ // LRU-style eviction kicks in once the size cap is exceeded.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_inMemoryStorageEvictsOnceCapExceeded() {
+ var s = new RateLimitGuard.InMemoryStorage(4);
+ for (var i = 0; i < 10; i++)
+ s.tryAcquire("k-" + i, 1, 1.0);
+ assertTrue(s.size() <= 4, "expected size <= 4, was " +
s.size());
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // evict(ttl=0) sweeps every bucket older than zero nanoseconds — i.e.
all of them.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Test void a02_evictWithZeroTtlSweepsAll() throws InterruptedException {
+ var s = new RateLimitGuard.InMemoryStorage(100);
+ s.tryAcquire("a", 1, 1.0);
+ s.tryAcquire("b", 1, 1.0);
+ s.tryAcquire("c", 1, 1.0);
+ assertEquals(3, s.size());
+ Thread.sleep(5);
+ s.evict(Duration.ofNanos(1));
+ assertEquals(0, s.size());
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Long TTL keeps buckets in place.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Test void a03_evictWithLargeTtlKeepsBuckets() {
+ var s = new RateLimitGuard.InMemoryStorage(100);
+ s.tryAcquire("a", 1, 1.0);
+ s.evict(Duration.ofHours(1));
+ assertEquals(1, s.size());
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Constructor rejects non-positive maxKeys.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Test void a04_constructorRejectsNonPositiveMaxKeys() {
+ assertThrows(IllegalArgumentException.class, () -> new
RateLimitGuard.InMemoryStorage(0));
+ assertThrows(IllegalArgumentException.class, () -> new
RateLimitGuard.InMemoryStorage(-1));
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Storage.inMemory(int) factory wires the size cap correctly.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Test void a05_storageInMemoryFactoryWithCapacity() {
+ var s = RateLimitGuard.Storage.inMemory(2);
+ assertNotNull(s);
+ s.tryAcquire("a", 1, 1.0);
+ s.tryAcquire("b", 1, 1.0);
+ s.tryAcquire("c", 1, 1.0);
+ s.tryAcquire("d", 1, 1.0);
+ s.evict(Duration.ofHours(1));
+ assertNotNull(s);
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Custom storage backend can be wired via Builder.storage(...).
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Test void a06_customStorageBackendIsWiredThroughBuilder() {
+ var custom = RateLimitGuard.Storage.inMemory(50);
+ var g = RateLimitGuard.create()
+ .permitsPerSecond(1)
+ .burst(1)
+ .storage(custom)
+ .build();
+ assertNotNull(g);
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // secondsUntilFull is non-negative even when the bucket has refilled
past nominal capacity.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Test void a07_secondsUntilFullIsNonNegative() throws
InterruptedException {
+ var s = new RateLimitGuard.InMemoryStorage(10);
+ s.tryAcquire("a", 5, 1000.0);
+ Thread.sleep(20);
+ var r = s.tryAcquire("a", 5, 1000.0);
+ assertTrue(r.secondsUntilReset() >= 0L);
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_ExemptPaths_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_ExemptPaths_Test.java
new file mode 100644
index 0000000000..8bc3620a0e
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_ExemptPaths_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.guard;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+class RateLimitGuard_ExemptPaths_Test extends TestBase {
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Default exempt paths (/healthz, /readyz, /livez) bypass throttling.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest
+ public static class A extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @Bean public RestGuardList guards(BeanStore bs) {
+ return RestGuardList.create(bs).append(
+ RateLimitGuard.create()
+ .permitsPerSecond(1)
+ .burst(1)
+ .keyBy(req -> "static-exempt")
+ .build()
+ ).build();
+ }
+ @RestGet(path="/healthz")
+ public String healthz() { return "ok"; }
+ @RestGet(path="/readyz")
+ public String readyz() { return "ok"; }
+ @RestGet(path="/livez")
+ public String livez() { return "ok"; }
+ @RestGet(path="/a")
+ public String a() { return "ok"; }
+ }
+
+ @Test void a01_defaultExemptPathsBypassThrottling() throws Exception {
+ var c = MockRestClient.buildLax(A.class);
+ for (var i = 0; i < 5; i++) {
+ c.get("/healthz").run().assertStatus(200);
+ c.get("/readyz").run().assertStatus(200);
+ c.get("/livez").run().assertStatus(200);
+ }
+ c.get("/a").run().assertStatus(200);
+ c.get("/a").run().assertStatus(429);
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Custom exempt paths replace the defaults.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest
+ public static class B extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @Bean public RestGuardList guards(BeanStore bs) {
+ return RestGuardList.create(bs).append(
+ RateLimitGuard.create()
+ .permitsPerSecond(1)
+ .burst(1)
+ .keyBy(req -> "static-custom-exempt")
+ .exemptPaths("/special")
+ .build()
+ ).build();
+ }
+ @RestGet(path="/special")
+ public String special() { return "ok"; }
+ @RestGet(path="/healthz")
+ public String healthz() { return "ok"; }
+ }
+
+ @Test void b01_customExemptPathsReplaceDefaults() throws Exception {
+ var c = MockRestClient.buildLax(B.class);
+ for (var i = 0; i < 3; i++)
+ c.get("/special").run().assertStatus(200);
+ c.get("/healthz").run().assertStatus(200);
+ c.get("/healthz").run().assertStatus(429);
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Empty exempt-paths list throttles even the probe paths.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest
+ public static class C extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @Bean public RestGuardList guards(BeanStore bs) {
+ return RestGuardList.create(bs).append(
+ RateLimitGuard.create()
+ .permitsPerSecond(1)
+ .burst(1)
+ .keyBy(req -> "static-no-exempt")
+ .exemptPaths()
+ .build()
+ ).build();
+ }
+ @RestGet(path="/healthz")
+ public String healthz() { return "ok"; }
+ }
+
+ @Test void c01_emptyExemptPathsThrottlesEveryPath() throws Exception {
+ var c = MockRestClient.buildLax(C.class);
+ c.get("/healthz").run().assertStatus(200);
+ c.get("/healthz").run().assertStatus(429);
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_KeyIsolation_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_KeyIsolation_Test.java
new file mode 100644
index 0000000000..6876489193
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_KeyIsolation_Test.java
@@ -0,0 +1,93 @@
+/*
+ * 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 static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+class RateLimitGuard_KeyIsolation_Test extends TestBase {
+
+ @Rest
+ public static class A extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @Bean public RestGuardList guards(BeanStore bs) {
+ return RestGuardList.create(bs).append(
+ RateLimitGuard.create()
+ .permitsPerSecond(1)
+ .burst(1)
+ .keyBy(req -> req.getHeader("X-Tenant"))
+ .exemptPaths()
+ .build()
+ ).build();
+ }
+ @RestGet(path="/a")
+ public String a() { return "ok"; }
+ }
+
+ @Test void a01_differentKeysHaveIndependentBuckets() throws Exception {
+ var c = MockRestClient.buildLax(A.class);
+ c.get("/a").header("X-Tenant", "alpha").run().assertStatus(200);
+ c.get("/a").header("X-Tenant", "alpha").run().assertStatus(429);
+ c.get("/a").header("X-Tenant", "beta").run().assertStatus(200);
+ c.get("/a").header("X-Tenant", "beta").run().assertStatus(429);
+ c.get("/a").header("X-Tenant", "gamma").run().assertStatus(200);
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Storage SPI directly: same key shares state, different keys are
isolated.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_storageIsolatesKeys() {
+ var storage = RateLimitGuard.Storage.inMemory();
+ assertTrue(storage.tryAcquire("k1", 1, 1.0).allowed());
+ assertFalse(storage.tryAcquire("k1", 1, 1.0).allowed());
+ assertTrue(storage.tryAcquire("k2", 1, 1.0).allowed());
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Null key from resolver is normalized to empty string.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest
+ public static class B extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @Bean public RestGuardList guards(BeanStore bs) {
+ return RestGuardList.create(bs).append(
+ RateLimitGuard.create()
+ .permitsPerSecond(1)
+ .burst(1)
+ .keyBy(req -> null)
+ .exemptPaths()
+ .build()
+ ).build();
+ }
+ @RestGet(path="/b")
+ public String b() { return "ok"; }
+ }
+
+ @Test void b02_nullKeyNormalizedToEmptyStringSharedBucket() throws
Exception {
+ var c = MockRestClient.buildLax(B.class);
+ c.get("/b").run().assertStatus(200);
+ c.get("/b").run().assertStatus(429);
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_Test.java
new file mode 100644
index 0000000000..0fcd3851bd
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_Test.java
@@ -0,0 +1,192 @@
+/*
+ * 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 static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.http.response.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+class RateLimitGuard_Test extends TestBase {
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Burst drain + refill + 429 + Retry-After
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest
+ public static class A extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @Bean public RestGuardList guards(BeanStore bs) {
+ return RestGuardList.create(bs).append(
+ RateLimitGuard.create()
+ .permitsPerSecond(1)
+ .burst(3)
+ .keyBy(req -> "static-burst-drain")
+ .exemptPaths()
+ .build()
+ ).build();
+ }
+ @RestGet(path="/a")
+ public String a() { return "ok"; }
+ }
+
+ @Test void a01_burstDrainsThenRejectsWith429AndRetryAfter() throws
Exception {
+ var c = MockRestClient.buildLax(A.class);
+ c.get("/a").run().assertStatus(200);
+ c.get("/a").run().assertStatus(200);
+ c.get("/a").run().assertStatus(200);
+ c.get("/a").run()
+ .assertStatus(429)
+ .assertHeader("Retry-After").asInteger().isExists();
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Refill after wait
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest
+ public static class B extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @Bean public RestGuardList guards(BeanStore bs) {
+ return RestGuardList.create(bs).append(
+ RateLimitGuard.create()
+ .permitsPerSecond(100)
+ .burst(1)
+ .keyBy(req -> "static")
+ .exemptPaths()
+ .build()
+ ).build();
+ }
+ @RestGet(path="/b")
+ public String b() { return "ok"; }
+ }
+
+ @Test void b01_bucketRefillsAtConfiguredRate() throws Exception {
+ var c = MockRestClient.buildLax(B.class);
+ c.get("/b").run().assertStatus(200);
+ c.get("/b").run().assertStatus(429);
+ Thread.sleep(50);
+ c.get("/b").run().assertStatus(200);
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Defaults reject zero/negative configuration
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_builderRejectsNonPositiveRate() {
+ assertThrows(IllegalArgumentException.class, () ->
RateLimitGuard.create().permitsPerSecond(0).burst(1).build());
+ }
+
+ @Test void c02_builderRejectsNonPositiveBurst() {
+ assertThrows(IllegalArgumentException.class, () ->
RateLimitGuard.create().permitsPerSecond(1).burst(0).build());
+ }
+
+ @Test void c03_builderRejectsNullKeyResolver() {
+ assertThrows(IllegalArgumentException.class, () ->
RateLimitGuard.create().keyBy(null));
+ }
+
+ @Test void c04_builderRejectsNullStorage() {
+ assertThrows(IllegalArgumentException.class, () ->
RateLimitGuard.create().storage(null));
+ }
+
+ @Test void c05_builderRejectsNullExemptPaths() {
+ assertThrows(IllegalArgumentException.class, () ->
RateLimitGuard.create().exemptPaths((String[])null));
+ }
+
+ @Test void c06_builderRejectsNullOnLimitExceeded() {
+ assertThrows(IllegalArgumentException.class, () ->
RateLimitGuard.create().whenLimitExceeded(null));
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // permitsPerMinute / permitsPerHour wiring
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Test void d01_permitsPerMinuteAndHourSetRate() {
+ var g1 =
RateLimitGuard.create().permitsPerMinute(60).burst(1).build();
+ var g2 =
RateLimitGuard.create().permitsPerHour(3600).burst(1).build();
+ assertNotNull(g1);
+ assertNotNull(g2);
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // onLimitExceeded callback fires on rejection
+
//------------------------------------------------------------------------------------------------------------------
+
+ private static volatile boolean E_FIRED;
+
+ @Rest
+ public static class E extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @Bean public RestGuardList guards(BeanStore bs) {
+ return RestGuardList.create(bs).append(
+ RateLimitGuard.create()
+ .permitsPerSecond(1)
+ .burst(1)
+ .keyBy(req -> "static")
+ .exemptPaths()
+ .whenLimitExceeded((req, info) ->
E_FIRED = true)
+ .build()
+ ).build();
+ }
+ @RestGet(path="/e")
+ public String e() { return "ok"; }
+ }
+
+ @Test void e01_onLimitExceededCallbackFiresOnRejection() throws
Exception {
+ E_FIRED = false;
+ var c = MockRestClient.buildLax(E.class);
+ c.get("/e").run().assertStatus(200);
+ c.get("/e").run().assertStatus(429);
+ assertTrue(E_FIRED);
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // TooManyRequests exception carries advisory headers + Retry-After
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Test void f01_tooManyRequestsCarriesAdvisoryHeadersAndRetryAfter() {
+ var storage = RateLimitGuard.Storage.inMemory();
+ assertTrue(storage.tryAcquire("k", 1, 1.0).allowed());
+ var result = storage.tryAcquire("k", 1, 1.0);
+ assertFalse(result.allowed());
+ assertEquals(0, result.remaining());
+ assertTrue(result.secondsUntilReset() >= 1L);
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // isRequestAllowed always returns true (we override guard() instead)
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Test void g01_isRequestAllowedAlwaysTrue() {
+ var g =
RateLimitGuard.create().permitsPerSecond(1).burst(1).build();
+ assertTrue(g.isRequestAllowed(null));
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Sanity: TooManyRequests exception type is reachable
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Test void h01_tooManyRequestsExceptionTypeAvailable() {
+ assertEquals(429, TooManyRequests.STATUS_CODE);
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_XForwardedFor_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_XForwardedFor_Test.java
new file mode 100644
index 0000000000..a0a05a0c65
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/guard/RateLimitGuard_XForwardedFor_Test.java
@@ -0,0 +1,99 @@
+/*
+ * 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.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for the {@code xForwardedForAware} flag.
+ *
+ * <p>
+ * <b>Trusted-proxy assumption.</b> These tests deliberately exercise the
spoofable path to confirm the guard
+ * keys on the first {@code X-Forwarded-For} hop when opted in. Production
deployments must only enable the flag
+ * behind a reverse proxy that strips and rewrites the header.
+ */
+class RateLimitGuard_XForwardedFor_Test extends TestBase {
+
+ @Rest
+ public static class A extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @Bean public RestGuardList guards(BeanStore bs) {
+ return RestGuardList.create(bs).append(
+ RateLimitGuard.create()
+ .permitsPerSecond(1)
+ .burst(1)
+ .xForwardedForAware(true)
+ .exemptPaths()
+ .build()
+ ).build();
+ }
+ @RestGet(path="/a")
+ public String a() { return "ok"; }
+ }
+
+ @Test void a01_keyResolvesFromFirstXForwardedForHop() throws Exception {
+ var c = MockRestClient.buildLax(A.class);
+ c.get("/a").header("X-Forwarded-For", "203.0.113.10,
10.0.0.1").run().assertStatus(200);
+ c.get("/a").header("X-Forwarded-For", "203.0.113.10,
10.0.0.2").run().assertStatus(429);
+ c.get("/a").header("X-Forwarded-For",
"203.0.113.11").run().assertStatus(200);
+ }
+
+ @Test void a02_xForwardedForAbsentFallsBackToDefaultKey() throws
Exception {
+ var c = MockRestClient.buildLax(A.class);
+ c.get("/a").remoteAddr("198.51.100.1").run().assertStatus(200);
+ c.get("/a").remoteAddr("198.51.100.1").run().assertStatus(429);
+ c.get("/a").remoteAddr("198.51.100.2").run().assertStatus(200);
+ }
+
+ @Test void a03_xForwardedForBlankFallsBackToDefaultKey() throws
Exception {
+ var c = MockRestClient.buildLax(A.class);
+ c.get("/a").header("X-Forwarded-For", "
").remoteAddr("198.51.100.5").run().assertStatus(200);
+ c.get("/a").header("X-Forwarded-For", "
").remoteAddr("198.51.100.5").run().assertStatus(429);
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Default behavior: X-Forwarded-For ignored when xForwardedForAware
not enabled.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest
+ public static class B extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @Bean public RestGuardList guards(BeanStore bs) {
+ return RestGuardList.create(bs).append(
+ RateLimitGuard.create()
+ .permitsPerSecond(1)
+ .burst(1)
+ .exemptPaths()
+ .build()
+ ).build();
+ }
+ @RestGet(path="/b")
+ public String b() { return "ok"; }
+ }
+
+ @Test void b01_xForwardedForIgnoredWhenAwarenessOff() throws Exception {
+ var c = MockRestClient.buildLax(B.class);
+
c.get("/b").remoteAddr("198.51.100.20").header("X-Forwarded-For",
"203.0.113.99").run().assertStatus(200);
+
c.get("/b").remoteAddr("198.51.100.20").header("X-Forwarded-For",
"203.0.113.99").run().assertStatus(429);
+
c.get("/b").remoteAddr("198.51.100.21").header("X-Forwarded-For",
"203.0.113.99").run().assertStatus(200);
+ }
+}
diff --git a/todo/TODO-66-rate-limit-and-request-id.md
b/todo/FINISHED-66-rate-limit-and-request-id.md
similarity index 100%
rename from todo/TODO-66-rate-limit-and-request-id.md
rename to todo/FINISHED-66-rate-limit-and-request-id.md
diff --git a/todo/TODO-73-rest-paths-runtime-override.md
b/todo/TODO-73-rest-paths-runtime-override.md
new file mode 100644
index 0000000000..9f9afff7f3
--- /dev/null
+++ b/todo/TODO-73-rest-paths-runtime-override.md
@@ -0,0 +1,153 @@
+# TODO-73: Runtime-overridable `@Rest(paths=...)` resolution chain
+
+Source: split out of the post-FINISHED-72 mixin-pack planning on 2026-05-23.
Foundational primitive for TODO-74 / 75 / 76 / 77 / 78.
+
+## Goal
+
+Make the URL patterns from `@Rest(paths=...)` runtime-overridable so
applications can rewire mixin paths (e.g. moving `/healthz` to `/health/live`
to match k8s conventions) without forking the mixin class. Mirrors how
`@Rest(path="...")` is already overridable through
`RestContext.Builder.path(String)`; this TODO extends the same affordance to
the multi-mount `paths` array shipped in FINISHED-72.
+
+End-state developer experience:
+
+```java
+// 1. Annotation default — always present.
+@Rest(paths={"/healthz","/readyz","/livez"})
+public class BasicHealthResource extends BasicRestServlet { ... }
+
+// 2. Importer overrides via getter (subclass / mixin host).
+@Rest(mixins=BasicHealthResource.class, paths={"/api"})
+public class ApiResource extends BasicRestServlet {
+ @Override public String[] getPaths() { return new
String[]{"/health/live","/health/ready"}; }
+}
+
+// 3. Importer overrides via config key (Juneau Config or Spring Environment).
+@Rest(mixins=BasicHealthResource.class, pathsKey="health.paths")
+public class ApiResource extends BasicRestServlet { ... }
+
+// 4. Programmatic override on the builder — wins over everything.
+@RestInit
+public void onInit(RestContext.Builder b) {
+ b.paths("/health/live","/health/ready");
+}
+```
+
+Documented precedence: **programmatic > config-key > getter > annotation
default**.
+
+## Why now
+
+- Five sibling mixin TODOs (TODO-74 through TODO-78) each ship with a
`@Rest(paths=...)` default. Without this primitive, every one of them would
need to invent its own override pattern. Land this once, reuse everywhere.
+- `@Rest(path="...")` already supports a programmatic
`RestContext.Builder.path(String)` setter, so half the precedence chain already
exists for the scalar case — extending it to the array case is mostly a parity
exercise.
+- Spring Boot users already expect `@Value("${health.paths}")` style
externalization; FINISHED-72 currently forces them to subclass.
+- The change is additive — null/empty getter return falls back to the
annotation, so existing FINISHED-72 callers see no behavior change.
+
+## Scope
+
+**In scope (v1):**
+
+- `RestContext.Builder.paths(String...)` programmatic setter (parallel to
existing `path(String)` setter).
+- `RestObject#getPaths()` / `RestServlet#getPaths()` virtual method on the
canonical resource base classes — default implementation returns `null` (=
"inherit annotation"). Subclasses can override to substitute paths at
construction time.
+- `@Rest(pathsKey="...")` annotation member — when set, the framework reads
`Config.getString(pathsKey)` (and, under the Spring-Boot-`BeanStore` adapter,
`Environment.getProperty(pathsKey)`) and parses the comma-delimited result as a
`String[]`. Empty / unset key falls through to the next rung.
+- Resolution occurs once during `RestContext` construction; the resolved
`String[]` is what `JettyServerComponent.restPathsFor(...)` (and Spring Boot's
`JuneauRestInitializer`) sees.
+- Documented precedence chain. Tests for each rung. Tests for the multi-mount
integration through Jetty's multi-pattern mount.
+
+**Explicitly out of scope (v1):**
+
+- Hot-reload of paths after `RestContext` construction. One resolution pass;
rebuild the context to re-resolve.
+- Per-request path rewriting. That's a different concern (URL rewriting,
reverse proxy territory).
+- Wildcard expansion in `pathsKey` — the resolved value is taken literally and
split on commas, no glob support.
+- Migration of `@Rest(path="...")` (singular) to use this same chain. The
singular case already has its own resolution; this TODO touches only the array
variant.
+
+## Dependency-injection notes
+
+- **Mixin/resource resolution is unchanged.** The FINISHED-72 mixin walk and
Spring `BeanStore` adapter
(`juneau-rest/juneau-rest-server-springboot/src/main/java/org/apache/juneau/rest/springboot/SpringBeanStore.java`)
already resolve resource-class instances from the active bean store. No new
plumbing is needed at the resource level — both microservice (`BasicBeanStore`
lookup) and Spring Boot (`SpringBeanStore` → `ApplicationContext.getBean(...)`)
paths produce a `RestContext.Builder` [...]
+- **Builder-time configuration sourcing.** The four override rungs are sourced
as follows under each path:
+ - **Programmatic setter** (`RestContext.Builder.paths(String...)`) —
identical under both paths; called from `@RestInit` or a
`RestServletInitializer` hook.
+ - **`getPaths()` getter** — identical under both paths; the importer
subclass overrides the method.
+ - **`pathsKey` config-key resolution** — under microservice, looks up
`BeanStore.getBean(Config.class)` → `Config.getString(pathsKey)`. Under Spring
Boot, the `SpringBeanStore`'s parent-chain returns the same `Config` if one was
registered, but the resolver also falls back to
`BeanStore.getBean(org.springframework.core.env.Environment.class)` →
`Environment.getProperty(pathsKey)` so Spring's standard property-resolution
chain (`application.yml`, system props, `--args`, profiles, etc. [...]
+ - **Annotation default** — identical under both paths; pure compile-time
literal.
+- **Spring-Boot-specific gotchas.**
+ - `Environment.getProperty(pathsKey)` returns a single `String`; we split
on `,` (trimmed) to match `Config.getStringArray(...)` semantics. Document
explicitly so users don't expect Spring's `String[]` array binding.
+ - `SpringBeanStore.getBean(Environment.class)` works only when the
resource is constructed through the `JuneauRestInitializer` adapter (which
seeds the bean store with the active `ApplicationContext`). Pure-microservice
deployments harmlessly miss the `Environment` lookup and fall through to the
next rung.
+ - `@Primary` / `@Qualifier` are not relevant for `Environment` (singleton)
but are relevant if a user later registers a custom `Config` bean — the
existing `BeanStore.getBean(Config.class)` returns the first match, which under
Spring follows `@Primary` semantics. No new contract.
+- **Acceptance bullet** added below: "Mixin works identically when registered
via Juneau `BeanStore` (microservice path) and via Spring `@Bean` (Spring Boot
path); both paths covered by a test."
+
+## Phased steps
+
+### Phase 0 — confirm seams (read-only)
+
+1. `RestContext.Builder` — confirm that `path(String)` already memoizes a
single resolved value at build time and that we can add `paths(String...)`
alongside without disturbing the singular flow.
+2. `JettyServerComponent.restPathsFor(...)` — confirm that the FINISHED-72
multi-pattern mount reads from `RestContext.getPaths()` (or equivalent) and not
directly from the annotation.
+3. `SpringBeanStore` — confirm that `BeanStore.getBean(Environment.class)`
resolves cleanly when the adapter is in use and returns `Optional.empty()`
otherwise.
+4. `Config.getString(...)` / `Config.getStringArray(...)` — confirm both are
present and which signature gives us cleanest comma-split semantics.
+
+### Phase 1 — programmatic + getter rungs
+
+1. Add `RestContext.Builder.paths(String... paths)` setter; null/empty array
clears.
+2. Add `RestServlet#getPaths()` and `RestObject#getPaths()` default-`null`
virtual methods.
+3. Wire both into the path-resolution chain in `RestContext` build, with
explicit precedence documented in the javadoc.
+4. Tests:
+ - `RestPathsRuntimeOverride_Programmatic_Test` — `Builder.paths(...)`
overrides annotation; null arg resets to annotation default; empty arg
explicitly clears (no mounts).
+ - `RestPathsRuntimeOverride_Getter_Test` — `getPaths()` override on
subclass beats annotation; `null` return falls through.
+
+### Phase 2 — `pathsKey` config-key rung
+
+1. Add `String pathsKey() default ""` to `@Rest`.
+2. Resolution code: when `pathsKey` is non-empty, look up `Config` from the
bean store and call `getStringArray(pathsKey)`; if result is empty, fall
through.
+3. Tests:
+ - `RestPathsRuntimeOverride_ConfigKey_Test` — `pathsKey` resolves from a
`Config` bean; missing key falls through; empty value falls through.
+
+### Phase 3 — Spring `Environment` fallback
+
+1. In the `pathsKey` resolver, after the `Config` miss, also try
`BeanStore.getBean(Environment.class)` and split on `,`.
+2. Tests:
+ - `RestPathsRuntimeOverride_SpringEnvironment_Test` (in
`juneau-rest/juneau-rest-server-springboot` test sources) — Spring
`Environment.getProperty(...)` resolves the key when no Juneau `Config` is
registered.
+
+### Phase 4 — multi-mount integration
+
+1. Confirm `JettyServerComponent` and the Spring Boot `JuneauRestInitializer`
both consume the resolved `String[]` (not the raw annotation).
+2. Tests:
+ - `RestPathsRuntimeOverride_JettyMount_Test` — programmatic override
produces correct exact-match mounts under Jetty.
+ - `RestPathsRuntimeOverride_Springboot_Test` — same under Spring Boot's
embedded servlet container, exercising the Spring `BeanStore` adapter
end-to-end.
+
+### Phase 5 — docs + release notes
+
+1. Release-notes entry under `### juneau-rest-server` and `###
juneau-rest-server-springboot`.
+2. New section in `docs/pages/topics/RestServerComposition.md` titled
"Runtime-overridable paths" with the precedence table, plus a one-row migration
note in the 9.5.0 migration guide if any FINISHED-72 caller's behavior shifts
(expected: none, fully additive).
+
+## Acceptance criteria
+
+- [ ] `RestContext.Builder.paths(String...)` overrides `@Rest(paths=...)` and
is the highest-priority rung.
+- [ ] `getPaths()` override on a `RestServlet`/`RestObject` subclass beats the
annotation when programmatic setter is unused.
+- [ ] `@Rest(pathsKey="x")` reads from a registered Juneau `Config`; missing
key falls through.
+- [ ] When deployed through `juneau-rest-server-springboot`, `pathsKey`
additionally resolves from Spring's `Environment` (after the `Config` miss).
+- [ ] Empty annotation default + empty getter + empty config-key + no
programmatic call → resource has no top-level mounts and a clear error message
names the resource.
+- [ ] `null` getter return is treated as "inherit annotation"; explicit empty
array (`new String[0]`) clears the mount list.
+- [ ] Mixin works identically when registered via Juneau `BeanStore`
(microservice path) and via Spring `@Bean` (Spring Boot path); both paths
covered by a test.
+- [ ] Coverage ≥ 95% on the new resolution code. Full `./scripts/test.py`
green.
+
+## Open questions
+
+1. **Precedence order.** Programmatic > config-key > getter > annotation.
**Recommend yes** — programmatic wins because it's the most explicit rung;
getter sits between code and config because subclass authors control it
directly; annotation is the fallback. Consistent with how `path(String)`
resolves today.
+2. **Null vs empty semantics.** `getPaths()` returning `null` means "inherit
annotation"; returning `new String[0]` means "explicitly clear, no mounts".
**Recommend that exact semantic** — gives subclasses a way to delete mounts
entirely (rare but legal).
+3. **Single vs multi getter.** Keep the existing scalar `getPath()` for
`@Rest(path="...")` legacy single-pattern accessor; new `getPaths()` is
array-canonical and orthogonal. **Recommend two getters, no merge** — they
serve different annotations.
+4. **`pathsKey` value format.** Comma-delimited string parsed at resolve time
(e.g. `"/healthz,/readyz"`). **Recommend comma-delimited** — matches
`Config.getStringArray(...)` and Spring's standard `String[]` binding.
+5. **Spring `@Value` / `Environment` integration.** Should
`@Rest(pathsKey="...")` resolve from Spring's `Environment` automatically when
running under Spring Boot, in addition to Juneau's `Config`? **Recommend yes**
— when the resource is resolved through the Spring-`BeanStore` adapter, fall
back to `Environment.getProperty(pathsKey)` if the Juneau `Config` lookup
misses. Documented as a Spring-Boot-path enhancement.
+6. **SVL variable resolution in `pathsKey`-loaded values.** Should the loaded
value run through SVL like other annotation values? **Recommend yes** for
parity with the rest of `@Rest`'s string-typed members;
`${env.HEALTH_PATHS:/healthz,/readyz}` should work uniformly.
+
+## Risks
+
+- **Precedence-rung ambiguity.** Users who set both a getter and a config key
may be surprised by which wins. Mitigation: document the precedence in javadoc
on `pathsKey`, and emit a `Logger.fine(...)` line at resolution time naming the
chosen rung.
+- **Spring `Environment` fallback masking missing `Config`.** A user who
intends to read from `Config` but typos the key may silently fall through to
`Environment` and pick up an unrelated value. Mitigation: log when the fallback
fires; document loudly.
+- **Multi-mount × programmatic-override interaction with collision
detection.** FINISHED-72's importer-wins rule operates on the *resolved* paths;
a runtime override that collides with another mounted resource needs to fail
loudly (not silently overwrite). Mitigation: collision check runs on the
resolved `String[]`, not on the annotation literal.
+- **Eclipse / Maven incremental-build staleness.** `@Rest(pathsKey=...)` is
read at runtime, but Eclipse may cache annotation values. Mitigation: AGENTS.md
"Build Automatically" caveat; document.
+
+## Related work
+
+- `todo/FINISHED-72-rest-mixins-and-paths.md` — established `@Rest(paths=...)`
and the multi-mount Jetty plumbing this TODO extends.
+- `todo/TODO-74-mixin-api-docs.md` (sibling) — soft dependency; api-docs
mixin's `paths={"/api","/openapi", ...}` defaults benefit from this primitive.
+- `todo/TODO-75-mixin-static-files.md` (sibling) — soft dependency;
static-files mixin's `paths={"/static/*","/htdocs/*"}` defaults benefit.
+- `todo/TODO-76-mixin-convention-endpoints.md` (sibling) — soft dependency;
favicon/seo/version/well-known mixins all default-configured paths.
+- `todo/TODO-77-mixin-ops-introspection.md` (sibling) — soft dependency;
admin/echo/route-index mixins benefit from `pathsKey`-driven prod/staging
variance.
+- `todo/TODO-78-mixin-jsp-module.md` (sibling) — soft dependency; JSP mixin's
`paths={"/jsp/*"}` default benefits.
+- `juneau-rest/juneau-rest-server-springboot/` — Spring `BeanStore` adapter
module; this TODO adds the `Environment` fallback hook.
+- `juneau-microservice/` and the `BeanStore` walk in `RestContext` — the
microservice-path equivalent the same resolver runs against by default.
+- Existing: `RestContext.Builder.path(String)` — the scalar precedent this
TODO mirrors for the array case.
diff --git a/todo/TODO-74-mixin-api-docs.md b/todo/TODO-74-mixin-api-docs.md
new file mode 100644
index 0000000000..0814e30683
--- /dev/null
+++ b/todo/TODO-74-mixin-api-docs.md
@@ -0,0 +1,137 @@
+# TODO-74: API-docs mixin (`BasicApiDocsResource`)
+
+Source: split out of the post-FINISHED-72 mixin-pack planning on 2026-05-23.
+
+## Goal
+
+Extract the existing `/api`, `/swagger`, `/openapi`, `/openapi.json`,
`/openapi.yaml`, and `/redoc` endpoints from `BasicGroupOperations` /
`BasicRestServlet` into a standalone, mixin-able `BasicApiDocsResource`. The
first highest-value mixin candidate after `BasicHealthResource` (which landed
in FINISHED-72): API documentation today is gated behind inheriting from
`BasicRestServlet` / `BasicRestObject`, but plain `RestServlet` apps should be
able to opt in via `@Rest(mixins=BasicApiDocs [...]
+
+End-state developer experience:
+
+```java
+// Path A — plain RestServlet gains the api-docs surface as a mixin.
+@Rest(path="/api", mixins=BasicApiDocsResource.class)
+public class ApiResource extends RestServlet {
+ @RestGet("/items") public List<Item> items() { ... }
+ // Now also serves /api/swagger, /api/openapi.json, /api/redoc, etc.
+}
+
+// Path B — standalone API-docs deployment via the multi-mount story (TODO-73
+ FINISHED-72).
+@Rest(paths={"/api","/swagger","/openapi","/openapi.json","/openapi.yaml","/redoc"})
+public class BasicApiDocsResource extends BasicRestServlet { ... }
+
+// Pinned versioned mounts.
+@Rest(mixins=BasicApiDocsResource.class, paths={"/api/v3.1"})
+public class V31Docs extends BasicRestServlet {
+ @Override public String getApiFormat() { return "openapi"; }
+}
+```
+
+## Why now
+
+- FINISHED-72 added `@Rest(mixins=...)` and `@Rest(paths=...)` precisely so
single-purpose servlet bundles like this could be composed in.
+- Today every Juneau service that wants Swagger UI / Redoc / OpenAPI 3.1 must
extend the `BasicRestServlet` chain. The FINISHED-63 OpenAPI 3.1 work and the
existing `apiFormat` knob already produce the artifacts; only the mounting
surface needs lifting.
+- Pairs naturally with TODO-73 (runtime-overridable paths) — apps with
prefixed deployments (`/admin/api`, `/internal/api`) need the override hook so
they don't have to subclass.
+
+## Scope
+
+**In scope (v1):**
+
+- New class `org.apache.juneau.rest.docs.BasicApiDocsResource` (servlet-class
form) with default
`@Rest(paths={"/api","/swagger","/openapi","/openapi.json","/openapi.yaml","/redoc"})`.
+- `@RestOp`-group methods extracted directly from
`BasicGroupOperations.getChildrenSwagger(...)` / `getChildrenOpenApi(...)` plus
the dedicated `/swagger`, `/openapi.json`, `/openapi.yaml`, `/redoc` endpoints.
Honors per-importer `@Rest(apiFormat=...)` so `apiFormat="openapi"` shifts the
canonical doc to `/openapi/*` while `apiFormat="both"` keeps both sets live.
+- `?Swagger` / `?OpenApi` query mirrors continue to work from the existing
`BasicGroupOperations` interface (mixin only adds the dedicated paths, doesn't
remove the query overload).
+- Schema generation reuses the existing `OpenApiProvider` / `SwaggerProvider`
infrastructure — no new schema code paths.
+- `BasicRestServlet` / `BasicRestObject` / `BasicGroupOperations` updated to
lean on the mixin internally so the user-visible endpoints don't change but
duplication is removed (preserves back-compat).
+- Tests in `juneau-utest`: mount-as-mixin, mount-as-standalone-via-paths,
query-mirror still works, format pinning works, version-pinned mounts work.
+
+**Explicitly out of scope (v1):**
+
+- Custom OpenAPI extension fields (`x-foo` namespacing helpers) — separate
concern, separate TODO if requested.
+- New schema dialects beyond Swagger v2 + OpenAPI 3.0 + 3.1 (already supported
in core).
+- Redoc / Swagger-UI theme customization beyond the existing `HtmlDocConfig`
surface.
+- Generation of API docs for `@RestStartCall` / `@RestEndCall` / filter beans
— only `@RestOp` methods.
+
+## Dependency-injection notes
+
+- **Mixin instance resolution.** `BasicApiDocsResource` is instantiated via
the FINISHED-72 mixin walk: the importer's bean store is queried via
`BeanStore.getBean(BasicApiDocsResource.class)` first, and if no bean is
registered, the framework reflects a no-arg constructor. Both microservice
(`BasicBeanStore`) and Spring Boot (`SpringBeanStore` →
`ApplicationContext.getBean(...)`) paths use this lookup verbatim — no new
plumbing required.
+- **Builder-time configuration sourcing.** The mixin reads two builder-time
inputs — the `apiFormat` value and the active
`OpenApiProvider`/`SwaggerProvider`:
+ - `apiFormat` is annotation-only on the importer's `@Rest` (resolved via
`RestContext.getApiFormat()`); identical under both DI paths.
+ - `OpenApiProvider` / `SwaggerProvider` are looked up from the bean store
(`BeanStore.getBean(OpenApiProvider.class)` etc.). Microservice users register
them via `@Bean OpenApiProvider provider() { ... }` on the importer; Spring
Boot users register `@Bean OpenApiProvider provider()` in a `@Configuration`
and the `SpringBeanStore` adapter exposes them. The mixin must NOT cache the
provider in a static — both paths rely on per-`RestContext` lookup.
+- **Spring-Boot-specific gotchas.**
+ - When a Spring Boot application has multiple `OpenApiProvider` candidates
(rare, but possible if a user registers both a default and a custom provider),
Spring's `@Primary`/`@Qualifier` semantics flow through
`SpringBeanStore.getBean(OpenApiProvider.class)` correctly because the adapter
delegates to `ApplicationContext.getBeanProvider(...).getIfAvailable()`.
Document the precedence so users who hit the multi-provider case know to mark
one `@Primary`.
+ - Classpath resource resolution for any embedded HTML/JS assets the Redoc
/ Swagger-UI handlers serve (`HtmlDocConfig`-driven) must use the importer's
classloader, not the `BasicApiDocsResource` classloader. The existing
`ResourceSupplier` lookup handles this; the mixin must use
`getContext().getResourceSupplier()` rather than `getClass().getClassLoader()`.
+- **Acceptance bullet** added below: "Mixin works identically when registered
via Juneau `BeanStore` (microservice path) and via Spring `@Bean` (Spring Boot
path); both paths covered by a test."
+
+## Phased steps
+
+### Phase 0 — confirm seams (read-only)
+
+1. `BasicGroupOperations` interface — confirm the existing `?Swagger` /
`?OpenApi` query-mirror endpoints. Inspect
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicGroupOperations.java`.
+2. `BasicRestServlet.getHtdoc(...)` and the static-file integration that backs
Swagger UI / Redoc — confirm it doesn't conflict with TODO-75's static-files
mixin.
+3. `OpenApiProvider` / `SwaggerProvider` `Void.class` defaults — confirm the
bean-store fallback resolution chain works the same way it does for
`BasicRestServlet`.
+4. `RestContext.getApiFormat()` resolution — confirm the system-property +
annotation precedence is unchanged so the mixin sees the right format.
+
+### Phase 1 — `BasicApiDocsResource` extraction
+
+1. New class `org.apache.juneau.rest.docs.BasicApiDocsResource` with the six
default paths and the `@RestOp` methods.
+2. Move the `?Swagger` / `?OpenApi` matchers into the docs package as static
inner classes (or reference the existing ones in `BasicGroupOperations`
directly).
+3. Update `BasicRestServlet` / `BasicRestObject` / `BasicGroupOperations` to
lean on the mixin via `@Rest(mixins=BasicApiDocsResource.class)` so the
user-visible endpoints remain identical, but the mounting code lives in one
place.
+4. Tests:
+ - `BasicApiDocsResource_AsMixin_Test` — mount on a vanilla `RestServlet`
via `@Rest(mixins=...)`, assert all six endpoints serve identical content to a
`BasicRestServlet` subclass.
+ - `BasicApiDocsResource_Standalone_Test` — mount via
`paths={"/api","/swagger",...}` directly, no importer, verify endpoints under
each path.
+ - `BasicApiDocsResource_QueryMirror_Test` — `?Swagger` / `?OpenApi`
continue to overload `GET /` on a parent group resource.
+ - `BasicApiDocsResource_FormatPinning_Test` — `apiFormat="openapi"`
returns 404 on `/swagger` but 200 on `/openapi/*`; `apiFormat="swagger"` is the
inverse; `apiFormat="both"` serves both.
+
+### Phase 2 — versioned mounts
+
+1. Confirm version-pinned mounts via `paths={"/openapi/v3.0","/openapi/v3.1"}`
work without code change (FINISHED-72 multi-mount handles this).
+2. Tests:
+ - `BasicApiDocsResource_VersionPinned_Test` — `/openapi/v3.0` returns 3.0
spec; `/openapi/v3.1` returns 3.1 spec on the same resource via paired mixin
instances or `apiFormat` overrides.
+
+### Phase 3 — Spring Boot smoke test
+
+1. New test in `juneau-rest/juneau-rest-server-springboot` test sources.
+2. Tests:
+ - `BasicApiDocsResource_Springboot_Test` — register `BasicApiDocsResource`
as a Spring `@Bean`, mount it via `JuneauRestInitializer`, verify identical
content to the microservice mount; also exercise the multi-`OpenApiProvider`
Spring case (two `@Bean OpenApiProvider`s, one `@Primary`).
+
+### Phase 4 — docs + release notes
+
+1. Release-notes entry under `### juneau-rest-server` (new mixin) and a
cross-reference under `### juneau-rest-server-springboot`.
+2. New sub-section in `docs/pages/topics/BasicRestServletSwagger.md` titled
"Using `BasicApiDocsResource` as a mixin"; link from the
`RestServerComposition.md` topic.
+
+## Acceptance criteria
+
+- [ ] Mounting `@Rest(mixins=BasicApiDocsResource.class)` on a vanilla
`RestServlet` produces identical `/openapi.json` output to a `BasicRestServlet`
subclass.
+- [ ] Standalone mount via
`paths={"/api","/swagger","/openapi","/openapi.json","/openapi.yaml","/redoc"}`
works without any importer mixin.
+- [ ] `apiFormat="openapi"` correctly 404s `/swagger` but serves `/openapi/*`;
`apiFormat="both"` serves both surfaces.
+- [ ] `?Swagger` / `?OpenApi` query mirrors on parent group resources continue
to work after the extraction.
+- [ ] Version-pinned mounts (`/openapi/v3.0`, `/openapi/v3.1`) coexist on the
same resource.
+- [ ] No regression in `BasicRestServlet` / `BasicRestObject` user-visible
endpoints (full backwards compatibility on the existing surface).
+- [ ] Mixin works identically when registered via Juneau `BeanStore`
(microservice path) and via Spring `@Bean` (Spring Boot path); both paths
covered by a test.
+- [ ] Coverage ≥ 95% on `BasicApiDocsResource`. Full `./scripts/test.py` green.
+
+## Open questions
+
+1. **Version-pinned mounts (`/openapi/v3.0`, `/openapi/v3.1`) — ship now or
defer?** Cheap to ship with FINISHED-72's multi-mount + `apiFormat="both"`.
**Recommend ship now** — the FINISHED-63 OpenAPI 3.1 work already produced the
dual-format artifacts.
+2. **Redoc default theme.** Match Juneau's existing HTML stylesheet
conventions (the same approach Swagger-UI uses today via `HtmlDocConfig`)?
**Recommend yes** — mirror the Swagger-UI Juneau theme story for visual
consistency.
+3. **Default mount paths for `apiFormat="openapi"`.** Should the default
`paths` list still include `/swagger` (which would 404), or should it adapt at
resolution time to drop the irrelevant entry? **Recommend keep static paths**,
let `apiFormat` 404 the irrelevant ones — mount-time path resolution that
depends on `apiFormat` would couple two unrelated annotation members.
+4. **`BasicGroupOperations.getChildrenSwagger(...)` /
`getChildrenOpenApi(...)` — kept on the interface or moved to the mixin?**
**Recommend keep on the interface** — the query-mirror behavior is tied to `GET
/` and is a different routing seam than the dedicated paths.
+5. **Naming.** `BasicApiDocsResource` (recommended) vs `BasicApiResource` vs
`BasicSwaggerResource`. **Recommend `BasicApiDocsResource`** — neutral across
Swagger/OpenAPI, makes the "this is the docs surface" intent clear in import
statements.
+
+## Risks
+
+- **Subtle regression in the existing `BasicRestServlet` chain.** Refactoring
`BasicGroupOperations` to lean on the mixin must not change which `@RestOp`
method matches a given URL. Mitigation: keep the existing tests passing without
modification, then add the new mixin-mode tests.
+- **`apiFormat` × `paths` cross-product.** Five `apiFormat` values × N
possible `paths` overrides explodes the matrix. Mitigation: parameterize the
existing `BasicRestServlet_*` tests to also run via the mixin path; rely on
`apiFormat`'s existing 404 behavior to keep the matrix tractable.
+- **Static-asset duplication with TODO-75.** Swagger-UI and Redoc ship JS/CSS
assets via the static-files plumbing; if TODO-75's `BasicStaticFilesResource`
lands first with a path collision (e.g. `/static/swagger-ui.js`), the
importer-wins rule means a user who mixes both could shadow the docs assets.
Mitigation: documentation note plus a
`BasicApiDocsResource_StaticFilesCoexistence_Test`.
+- **Spring `@Primary` ambiguity for multiple `OpenApiProvider` beans.**
Document; the `SpringBeanStore` adapter delegates to
`getBeanProvider(...).getIfAvailable()` which honors `@Primary` correctly — but
a user with two unmarked beans gets `BeanDefinitionOverrideException` from
Spring, not a Juneau-friendly error.
+
+## Related work
+
+- `todo/FINISHED-72-rest-mixins-and-paths.md` — the `@Rest(mixins=...)` +
`@Rest(paths=...)` primitives this mixin builds on.
+- `todo/FINISHED-63-openapi-3.1-emission.md` — the OpenAPI 3.1 emission this
mixin exposes via `/openapi/*`.
+- `todo/TODO-73-rest-paths-runtime-override.md` (sibling, soft dependency) —
runtime override of `paths` lets users move `/api` to `/admin/api` without
subclassing.
+- `todo/TODO-75-mixin-static-files.md` (sibling) — coexistence testing for the
static-asset overlap with Swagger-UI / Redoc.
+- `todo/TODO-77-mixin-ops-introspection.md` (sibling) — the route-index mixin
overlaps in spirit with `/api`'s navigation surface; document that they're
complementary, not competing.
+- `juneau-rest/juneau-rest-server-springboot/` — Spring `BeanStore` adapter;
smoke test target for Phase 3.
+- `juneau-microservice/` and the `BeanStore` walk in `RestContext` —
microservice-path equivalent that the same mixin runs against by default.
+- Existing: `BasicGroupOperations`
(`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicGroupOperations.java`)
— source of truth for the existing endpoints being extracted.
diff --git a/todo/TODO.md b/todo/TODO.md
index 92fc2e5069..806d1342b5 100644
--- a/todo/TODO.md
+++ b/todo/TODO.md
@@ -6,8 +6,6 @@
- [TODO-37] - Agent instruction consolidation.
-- [TODO-66] Rate-limit guard + request-id propagation filter. See
`todo/TODO-66-rate-limit-and-request-id.md`.
-
- [TODO-67] Observability hooks — Micrometer + OpenTelemetry seams via
`MethodExecStats`. See `todo/TODO-67-observability-micrometer-otel.md`.
- [TODO-68] Bean Validation (Jakarta Validation 3.x) integration on request
beans. See `todo/TODO-68-bean-validation-integration.md`.