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 cd7a8e22ea docs: materialize TODO-18 brainstorm into TODO-61 through
TODO-70 (rest-server feature plans)
cd7a8e22ea is described below
commit cd7a8e22eafbc7e2e74a7eeee7f0cf9a8da5fb29
Author: James Bognar <[email protected]>
AuthorDate: Fri May 22 14:43:20 2026 -0400
docs: materialize TODO-18 brainstorm into TODO-61 through TODO-70
(rest-server feature plans)
---
todo/FINISHED-18-rest-server-feature-brainstorm.md | 40 ++++++
todo/TODO-61-rfc7807-server-side-wiring.md | 151 +++++++++++++++++++++
todo/TODO-62-sse-server-helpers.md | 113 +++++++++++++++
todo/TODO-63-openapi-3.1-emission.md | 127 +++++++++++++++++
todo/TODO-64-etag-conditional-get-helpers.md | 97 +++++++++++++
todo/TODO-65-health-readiness-liveness-probes.md | 109 +++++++++++++++
todo/TODO-66-rate-limit-and-request-id.md | 117 ++++++++++++++++
todo/TODO-67-observability-micrometer-otel.md | 120 ++++++++++++++++
todo/TODO-68-bean-validation-integration.md | 114 ++++++++++++++++
todo/TODO-69-authn-guards-jwt-apikey.md | 125 +++++++++++++++++
...O-70-async-completablefuture-virtual-threads.md | 119 ++++++++++++++++
todo/TODO.md | 26 +++-
12 files changed, 1255 insertions(+), 3 deletions(-)
diff --git a/todo/FINISHED-18-rest-server-feature-brainstorm.md
b/todo/FINISHED-18-rest-server-feature-brainstorm.md
new file mode 100644
index 0000000000..621fe8544f
--- /dev/null
+++ b/todo/FINISHED-18-rest-server-feature-brainstorm.md
@@ -0,0 +1,40 @@
+# FINISHED-18: juneau-rest-server feature brainstorm
+
+Archived from the `[TODO-18]` bullet on 2026-05-22 after the brainstorm
materialized into ten follow-on TODOs.
+
+## Outcome
+
+The original bullet — *"Investigate possible useful features to add to
juneau-rest-server"* — was an investigation request, not an implementation. The
investigation has now produced ten concrete follow-on TODOs (`TODO-61` through
`TODO-70`), each with its own self-contained plan file under `todo/`. The
brainstorm itself produced no code changes.
+
+## Ten follow-on TODOs
+
+Priority-ordered (recommended landing order is roughly numeric; each plan is
independent):
+
+| ID | Slug | One-liner |
+|----|------|-----------|
+| TODO-61 | `rfc7807-server-side-wiring` | Auto-emit
`application/problem+json` from uncaught `BasicHttpException`; `Problem`
return-value support. |
+| TODO-62 | `sse-server-helpers` | `RestResponse.sse()` fluent surface +
`SseBroadcaster` fan-out + heartbeat scheduler. |
+| TODO-63 | `openapi-3.1-emission` | New `OpenApiProvider` (sibling of
`SwaggerProvider`) + bundled Swagger UI auto-mount. |
+| TODO-64 | `etag-conditional-get-helpers` | `RestResponse.eTag(...)` /
`lastModified(...)` + `RestRequest.checkPreconditions()`. |
+| TODO-65 | `health-readiness-liveness-probes` | `/healthz` / `/readyz` /
`/livez` + `HealthIndicator` SPI. |
+| TODO-66 | `rate-limit-and-request-id` | Token-bucket `RateLimitGuard` +
`RequestIdFilter`. |
+| TODO-67 | `observability-micrometer-otel` | `MetricsRecorder` + `TracerHook`
SPIs, with opt-in Micrometer + OTel sub-modules. |
+| TODO-68 | `bean-validation-integration` | Honor `jakarta.validation`
constraints on `@Content` / `@FormData` / `@Request` bound beans. |
+| TODO-69 | `authn-guards-jwt-apikey` | `BearerTokenGuard`, `ApiKeyGuard`,
optional `juneau-rest-server-jwt` sub-module. |
+| TODO-70 | `async-completablefuture-virtual-threads` |
`AsyncResponseProcessor` for `CompletableFuture` returns + opt-in
virtual-thread dispatch. |
+
+The bullets are listed in `todo/TODO.md`; the per-id plan files are at
`todo/TODO-<id>-<slug>.md`.
+
+## Brainstorm methodology
+
+1. **Baseline read.** Reviewed `todo/TODO.md`, the three active TODOs
(`TODO-20-rest-debug-rethink.md`, `TODO-35-beanstore-test-injection.md`,
`TODO-37-agent-instructions-consolidation.md`), and the recent
rest-server-touching `FINISHED-*` archives (31, 33, 36, 38, 40, 41, 42, 45, 46,
47) to ground the candidate list in what just landed and avoid duplicating
shipped work.
+2. **Surveyed `juneau-rest-server`.** Inventoried package structure
(`annotation/`, `arg/`, `converter/`, `debug/`, `guard/`, `httppart/`,
`logger/`, `matcher/`, `processor/`, `staticfile/`, `stats/`, `swagger/`),
spot-checked `juneau-rest-server-springboot` and `juneau-rest-mock` for
integration patterns, and grepped for missing seams (async returns, ETag
helpers, observability hooks, auth providers, multipart, OpenAPI 3,
Problem-details wiring).
+3. **Cross-checked against the 9.5 migration guide**
(`juneau-docs/pages/topics/23.01.V9.5-migration-guide.md`) and
`juneau-docs/pages/release-notes/9.5.0.md` to make sure no candidate duplicated
already-shipped work in 9.5.
+4. **Cast a wide net** — generated 14 candidate features across observability,
security, content-negotiation, OpenAPI/docs, reactive/async, validation,
testing, lifecycle/DI, error handling, caching/perf, versioning. Narrowed to
the 10 most concrete (dropped: brotli/zstd encoder, JSON Patch wiring,
multipart parser improvements, API versioning helpers — each too small or too
speculative to merit a separate TODO).
+5. **Ranked by impact × feasibility.** The shortlisted top three (TODO-61,
TODO-62, TODO-63) became the recommended landing order; the remaining seven
(TODO-64 through TODO-70) were ordered by feasibility (S-sized first, then M,
then M/L).
+
+## Notes for downstream implementers
+
+- Several of these TODOs *compose*. TODO-61 (Problem-Details) is the natural
error-rendering target for TODO-68 (Bean Validation) and TODO-69 (AuthN).
TODO-66 (request-id) feeds TODO-67 (observability) and TODO-20 (debug-format).
TODO-70 (async) and TODO-62 (SSE) both benefit from virtual-thread dispatch.
The per-TODO "Related work" sections call these cross-links out explicitly.
+- All ten are post-9.5 — the hard-break window is closed, so every plan is
structured as **additive-only**. Any deprecations live in 9.6+.
+- No TODO depends on TODO-35 (beanstore test injection) shipping first, but
several would have nicer test ergonomics with it in place. Implementer's choice
on landing order.
diff --git a/todo/TODO-61-rfc7807-server-side-wiring.md
b/todo/TODO-61-rfc7807-server-side-wiring.md
new file mode 100644
index 0000000000..c34db9f9dd
--- /dev/null
+++ b/todo/TODO-61-rfc7807-server-side-wiring.md
@@ -0,0 +1,151 @@
+# TODO-61: RFC 7807 / 9457 Problem-Details server-side wiring
+
+Source: split out of TODO-18 brainstorm on 2026-05-22 (the recommended #1
pick).
+
+## Goal
+
+Add server-side wiring so that any Juneau REST resource can emit
`application/problem+json` (RFC 7807 / 9457) responses, both reactively
(translate uncaught `BasicHttpException` / unchecked `Throwable` into `Problem`
payloads when the client asks for it) and declaratively (`@RestOp` methods that
return `Problem` directly serialize to `application/problem+json` with the
correct `Content-Type` and status code). Provide a thin
`Problem.fromException(BasicHttpException)` adapter in `juneau-r [...]
+
+The end-state developer experience is:
+
+```java
+@Rest(path="/orders", problemDetails=true) // opt-in flag
+public class OrderResource {
+
+ @RestGet("/{id}")
+ public Order get(@Path long id) {
+ throw new NotFound("Order {0} not found", id); // → 404
application/problem+json
+ }
+
+ @RestPost
+ public Problem create(Order in) {
+ if (in.balance < in.amount)
+ return Problem.fromStatus(403, "Insufficient credit", "Balance
"+in.balance+" < amount "+in.amount)
+ .setType(URI.create("https://example.com/probs/out-of-credit"))
+ .set("balance", in.balance);
+ return null;
+ }
+}
+```
+
+## Why now
+
+- `juneau-bean-rfc7807` shipped in 9.5.0
(`FINISHED-45-juneau-bean-rfc7807.md`) and its archive explicitly parks this
work: *"Wiring a `@Rest` exception handler that auto-emits Problem for every
uncaught BasicHttpException… belongs in juneau-rest-server (not the bean
module) and is a separate TODO."*
+- `juneau-rest-server` was decoupled from `org.apache.http.*` in TODO-40
(`FINISHED-40-remove-hc45-from-rest-common-and-server.md`) and
`BasicHttpException` gained the full fluent-setter surface, so the adapter is
one-shot: `new
Problem().setStatus(e.getStatusCode()).setTitle(e.getStatusLine().getReasonPhrase()).setDetail(e.getMessage())`.
+- `ContentType.APPLICATION_PROBLEM_JSON` and `…APPLICATION_PROBLEM_XML`
constants already live in
`juneau-rest/juneau-rest-common/.../http/header/ContentType.java`.
+- The `ResponseProcessorList` slot is open — no architectural prerequisite. No
dependency on TODO-20 or TODO-35.
+
+## Scope
+
+**In scope (v1):**
+
+- New `Problem.fromException(BasicHttpException)` adapter — lands in
**`juneau-rest-common`** (so the `juneau-bean-rfc7807` module stays clean of
any `juneau-rest-common` dep, per the locked-in decision in
`FINISHED-45-juneau-bean-rfc7807.md`).
+- New `org.apache.juneau.rest.processor.ProblemDetailsProcessor` slotted into
`ResponseProcessorList` ahead of `ThrowableProcessor`. When the request
`Accept` matches `application/problem+json` *and* the active response carries a
`BasicHttpException` or a `Problem` bean, it serializes a `Problem` body with
the right `Content-Type` and HTTP status.
+- New opt-in flag on `@Rest` / `@RestOp` (`problemDetails=true`) that
registers the processor and bumps `application/problem+json` to the default
`Accept` priority for error responses on that resource.
+- Direct support for `@RestOp` methods returning `Problem` — the processor
sets `Content-Type: application/problem+json`, sets the HTTP status from
`Problem.getStatus()` (defaulting to 200 when null), and serializes through the
existing `JsonSerializer.DEFAULT`.
+- New `org.apache.juneau.bean.rfc7807.ProblemException` (lives in
`juneau-bean-rfc7807`, optional convenience) — a `RuntimeException` that wraps
a `Problem` and lets handlers `throw new ProblemException(problem)` without
manually building a `BasicHttpException`. **Locked decision needed:** whether
to ship this in v1 or defer (recommend v1).
+- Tests: unit + `MockRestClient`-based integration in `juneau-utest`,
mirroring the `Problem_RoundTrip_Test` shape from `FINISHED-45-*`.
+- Docs: a new release-notes entry under `juneau-rest-server` in
`juneau-docs/pages/release-notes/9.5.0.md` (or 9.5.1 if open), plus a new topic
page under `juneau-docs/pages/topics/` (slug `RestServerProblemDetails`).
+
+**Explicitly out of scope (v1):**
+
+- `application/problem+xml` rendering — the
`ContentType.APPLICATION_PROBLEM_XML` constant exists but the bean module
hasn't been built; defer to a sibling TODO.
+- RFC 7807 §3.2 typed-extension *registration* (callers can still set
extension fields via `Problem.set(key, value)` — that ships in the bean today).
+- Localization of `title` / `detail` via Juneau message bundles — defer.
+- Auto-translation of Jakarta Validation `ConstraintViolationException` into
`Problem.errors[]` — that's part of **TODO-68** (Bean Validation integration);
the hooks here will be designed to make it a one-class follow-on.
+- Bridging into reactive / `CompletableFuture` return types — orthogonal to
**TODO-70**.
+
+## Phased steps
+
+### Phase 0 — inventory & seam confirmation (read-only, 1–2 hours)
+
+1. Re-read
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/{ResponseProcessorList,ThrowableProcessor,HttpBodyProcessor,SerializedPojoProcessor}.java`
to confirm processor ordering and the `int process(RestOpSession)` contract.
+2. Confirm `RestResponse.getException()` /
`RestResponse.setException(Throwable)` is the canonical seat for the in-flight
exception (it is — `RestResponse.java` exposes both).
+3. Confirm `ContentType.APPLICATION_PROBLEM_JSON` is reachable from
`juneau-rest-server` after TODO-40/42 (`juneau-rest-server` depends on
`juneau-rest-common`, which owns the constant — verified).
+4. Re-read `FINISHED-45-juneau-bean-rfc7807.md` "Out of scope" line items to
make sure nothing has shifted.
+5. Decide whether the opt-in is `@Rest(problemDetails=true)` (annotation
flag), a `@Bean ProblemDetailsProcessor` registration, or both. **Recommend
both** (the annotation flag is the on-ramp; the bean is the override seam).
+
+### Phase 1 — adapter + processor (no annotation changes)
+
+Lands the core capability. No annotation surface; opt-in is "drop a `@Bean
ProblemDetailsProcessor` in your resource."
+
+1. Add the adapter. **The bean module deliberately has no `juneau-rest-common`
dep** (locked decision in `FINISHED-45-*`). Reroute: the adapter lives in
**`juneau-rest-common`** as a static helper class
`org.apache.juneau.bean.rfc7807.adapter.ProblemAdapters#fromException(BasicHttpException)`.
The package name keeps it discoverable from the bean's javadoc
cross-reference; the *class* lives in `juneau-rest-common`'s tree because
that's where the bidirectional dep is allowed.
+2. Add `org.apache.juneau.rest.processor.ProblemDetailsProcessor` in
`juneau-rest-server`. Implements `ResponseProcessor`. Skeleton:
+ - If `res.getException() instanceof BasicHttpException` and
`req.getHeader("Accept")` includes `application/problem+json` (or
`problemDetails=true` is set on the resource), build a `Problem` via the
adapter, set `Content-Type`, set the HTTP status from the exception, serialize
through `JsonSerializer.DEFAULT`. Return `FINISHED`.
+ - If `res.getContent(Object.class) instanceof Problem`, force
`Content-Type: application/problem+json`, set the HTTP status from
`Problem.getStatus()` (defaulting to 200 when null), serialize. Return
`FINISHED`.
+ - Otherwise return `NOT_PROCESSED`.
+3. Hook `ProblemDetailsProcessor` into the **default** `ResponseProcessorList`
in
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/config/DefaultConfig.java`
(or wherever the processor chain is composed — confirm in Phase 0), *ahead* of
`ThrowableProcessor`. **The processor is a no-op for non-Problem responses**,
so adding it to the default chain has zero runtime cost for users who never
touch it.
+4. Tests in `juneau-utest`:
+ - `ProblemDetailsProcessor_Test` — exercises (a) `BasicHttpException` →
Problem JSON, (b) `Problem` return value → Problem JSON, (c) ordinary `String`
return → unchanged, (d) `Accept` negotiation (only fires when client asked).
+ - `ProblemAdapters_Test` — covers the adapter exhaustively (status, title
from reason phrase, detail from message, null-safe).
+5. Coverage target: ≥ 90% on the new processor + adapter (mirrors the bar in
`FINISHED-45-juneau-bean-rfc7807.md`).
+6. No release-notes entry yet — Phase 2 ships the annotation surface and is
the user-visible cut line.
+
+### Phase 2 — annotation + opt-in builder hook
+
+Adds the discoverable on-ramp.
+
+1. Add `problemDetails` attribute to `@Rest` and to all six
`@RestGet`/`@RestPost`/`@RestPut`/`@RestPatch`/`@RestDelete`/`@RestOptions`
annotations (mirror the existing `noInherit` fanout). Default: `false`. When
`true`, the resource:
+ - Registers `ProblemDetailsProcessor` via the resource's bean store at
build time.
+ - Adds `application/problem+json` to the default response media types for
error paths (so a client with `Accept: */*` and a 4xx outcome gets
`application/problem+json` rather than `text/plain` or `application/json`).
+2. Add `org.apache.juneau.bean.rfc7807.ProblemException extends
RuntimeException` in `juneau-bean-rfc7807` (carries a `Problem`; `getStatus()`
returns the embedded status). **Stays in the bean module** — no
`juneau-rest-server` dep. The processor checks for `ProblemException`
specifically and unwraps it.
+3. Tests:
+ - `Rest_ProblemDetails_Annotation_Test` — `@Rest(problemDetails=true)`
end-to-end: throw `NotFound`, assert response is `application/problem+json`
with a well-formed body. Same for per-op `@RestGet(problemDetails=true)`.
+ - `ProblemException_Test` — confirms `throw new ProblemException(problem)`
produces the expected wire body via the processor.
+4. Release-notes entry in `juneau-docs/pages/release-notes/9.5.0.md` (or 9.5.1
/ 9.6.0 — pick whichever is open at land time) under `### juneau-rest-server` +
`### juneau-bean-rfc7807`.
+5. New doc page `juneau-docs/pages/topics/10.07.RestServerProblemDetails.md`
(slug `RestServerProblemDetails`) with a worked example, the annotation
reference, and the bean-store registration alternative. Sidebar entry under the
`juneau-rest-server` section.
+
+### Phase 3 (optional, recommended) — declarative problem-mapping for
arbitrary exceptions
+
+Lets users map a custom exception type to a custom `Problem` shape without
writing a processor.
+
+1. Add `org.apache.juneau.bean.rfc7807.ProblemMapper<T extends Throwable>` SPI
(in the bean module — pure interface, no rest deps): `Problem map(T exception)`.
+2. The `ProblemDetailsProcessor` resolves all `ProblemMapper` beans from the
bean store, picks the most-specific by exception class hierarchy, and uses the
result. Falls back to `Problem.fromException(BasicHttpException)` for
`BasicHttpException`s with no explicit mapper.
+3. Tests:
+ - `ProblemMapper_Test` — register `ProblemMapper<MyDomainException>`, throw
`MyDomainException`, assert the custom `Problem` body shape.
+4. Defer if Phase 2 ships first and there's no concrete caller.
+
+### Phase 4 (optional, deferred) — `application/problem+xml`
+
+Out of scope for v1, but parking the design: a sibling
`juneau-bean-rfc7807-xml` bean module + an `XmlSerializer.DEFAULT_NS`-driven
branch in `ProblemDetailsProcessor`. Touch only if a user files a request.
+
+## Acceptance criteria
+
+- [ ] `Problem.fromException(BasicHttpException)` adapter lands in
`juneau-rest-common`, with a `Problem_FromException_Test` covering
status/title/detail mapping for the 8 most-common `BasicHttpException`
subclasses (`BadRequest`, `Unauthorized`, `Forbidden`, `NotFound`, `Conflict`,
`InternalServerError`, `NotImplemented`, `ServiceUnavailable`).
+- [ ] `ProblemDetailsProcessor` is registered in the default
`ResponseProcessorList` ahead of `ThrowableProcessor`. With
`problemDetails=false` (default) the processor short-circuits as
`NOT_PROCESSED`.
+- [ ] `@Rest(problemDetails=true)` end-to-end: `MockRestClient` against a
resource throwing `NotFound` returns `404` with `Content-Type:
application/problem+json` and a body that round-trips through
`JsonParser.DEFAULT.parse(body, Problem.class)`.
+- [ ] `@RestOp` methods returning `Problem` set `Content-Type:
application/problem+json` and use `Problem.getStatus()` (or 200 if null) as the
HTTP status.
+- [ ] `ProblemException` round-trip: `throw new ProblemException(problem)`
produces the same wire body as returning the `Problem` directly.
+- [ ] Coverage ≥ 90% on `ProblemDetailsProcessor`, `Problem.fromException`,
`ProblemException`. Bean classes target 100% per the `code-conventions` skill.
+- [ ] Release-notes entry under `### juneau-rest-server` and `###
juneau-bean-rfc7807` in the active release-notes file. New topic page wired
into `juneau-docs/sidebars.ts`.
+- [ ] Full `./scripts/test.py` green; `./scripts/sonarqube.py
juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/`
clean for the new file.
+- [ ] No regression in the existing `ThrowableProcessor` chain —
`Rest_Exceptions_Test` (or whatever the canonical exception-handling test is in
`juneau-utest`) still passes unchanged with `problemDetails=false`.
+
+## Open questions (need user direction before Phase 1)
+
+1. **Adapter package & module.** Recommend `juneau-rest-common` as the home
for `Problem.fromException(...)`, named
`org.apache.juneau.bean.rfc7807.adapter.ProblemAdapters` (static helper class).
Alternative: ship it as a default static method on a new
`juneau-rest-common`-side interface. **Decision needed before Phase 1.**
+2. **Annotation name.** Recommend `problemDetails` (camelCase, matches the
existing `defaultRequestAttributes` / `defaultRequestHeaders` style).
Alternative: `rfc7807=true`. Recommend `problemDetails` — neutral with respect
to RFC 7807 vs 9457.
+3. **Default processor registration.** Recommend "always in the chain, no-op
when not opted-in." Alternative: only added when `problemDetails=true` is
detected on the resource. The always-on path is simpler and lower-risk (the
no-op cost is one `instanceof` check per response).
+4. **`ProblemException` ship in v1?** Recommend yes — costs ~30 LOC, removes
the only friction point ("how do I throw a custom Problem?"). Alternative:
defer to Phase 3 alongside `ProblemMapper`.
+5. **`Accept` negotiation policy.** Recommend: when `problemDetails=true`
*and* the response is an error (4xx / 5xx), emit `application/problem+json`
regardless of the client's `Accept` (the spec encourages this). When the
response is success and the method returns a `Problem`, honor `Accept`
strictly. **Confirm.**
+6. **Status code source-of-truth on `Problem` returns.** When a method returns
`Problem` with a non-null `status`, the processor uses it. When
`Problem.status` is null, fall back to the method's `@RestPost`/etc default
status (200/201), *not* a hard 200 — confirm this behaviour. (RFC 7807 §3.1
makes `status` OPTIONAL precisely so the HTTP status carries it.)
+7. **`Problem.type` default.** RFC 7807 §3.1: absent `type` means
`about:blank`. The bean today does *not* serialize `about:blank` on the wire
(preserves the absent-vs-explicit distinction — see the `FINISHED-45` design
notes). Confirm we keep that behavior at the server-emit boundary (i.e. don't
synthesize a `type:"about:blank"` on the way out).
+8. **Localization.** Out of scope for v1 — confirm. (Recommend: yes, defer;
`Messages` integration is its own design.)
+
+## Risks
+
+- **Processor ordering bugs.** Inserting ahead of `ThrowableProcessor` is the
obvious choice but easy to get wrong — covered by the `Rest_Exceptions_Test`
non-regression bar in the acceptance criteria. Mitigation: Phase 0 confirms the
chain ordering before any code change.
+- **Content-type contention.** A user who has
`@Rest(defaultAccept="application/json")` *and* `problemDetails=true` is asking
for two different defaults on errors. Locked policy in Open Question #5
resolves this; document it loudly.
+- **`Problem.status` vs HTTP status drift.** A method returning `new
Problem().setStatus(500)` from a handler chained off a `@RestGet` (default 200)
creates ambiguity. Decision in Open Question #6 makes the rule explicit ("if
`Problem.status` is set, it wins").
+- **Test-fixture sprawl.** RFC 7807 has many degrees of freedom (`type`
absent/set, extensions, nested errors). Mitigation: model the test matrix on
`Problem_RoundTrip_Test` from `FINISHED-45-*`, which already covers the bean
side; the server-side test matrix only adds the HTTP-layer concerns (status,
content-type, opt-in negotiation).
+- **Cross-cutting overlap with TODO-20 (Rest Debug Rethink).** If TODO-20
reworks `CallLogger` mid-effort, the `Problem` payload may want to appear in
`DebugFormat` output. Low risk — orthogonal concerns; flag for the TODO-20
implementer to keep `Problem` rendering in mind for `JsonFormat`.
+- **Future Bean Validation integration (TODO-68).** If TODO-68 lands next, the
processor's `Problem.errors[]` extension shape becomes the de facto contract.
Worth picking a shape now even if we don't implement TODO-68 (recommend
`errors: [{ field, message }]` to match Spring's
`MethodArgumentNotValidException` mapper).
+
+## Related work
+
+- `todo/FINISHED-45-juneau-bean-rfc7807.md` — the `Problem` bean module this
TODO consumes; the archive's "Out of scope" section explicitly names this
server-side wiring as the named follow-up.
+- `todo/FINISHED-40-remove-hc45-from-rest-common-and-server.md` — retyped
`BasicHttpException` onto the JDK-native types and gave it the fluent setter
surface this adapter relies on.
+- `todo/FINISHED-42-split-rest-common-classic.md` — split `juneau-rest-common`
from `juneau-rest-common-classic`; the adapter lives on the non-classic side.
+- `todo/TODO-20-rest-debug-rethink.md` — overlap on `Problem`-as-debug-payload
rendering; coordinate when TODO-20 designs `JsonFormat`.
+- `todo/TODO-68-bean-validation-integration.md` (sibling) — natural follow-on
for `Problem.errors[]` from `ConstraintViolationException`.
+- `todo/TODO-70-async-completablefuture-virtual-threads.md` (sibling) —
orthogonal; the processor needs to work for both sync and async returns when
TODO-70 lands.
diff --git a/todo/TODO-62-sse-server-helpers.md
b/todo/TODO-62-sse-server-helpers.md
new file mode 100644
index 0000000000..3f3a0eec49
--- /dev/null
+++ b/todo/TODO-62-sse-server-helpers.md
@@ -0,0 +1,113 @@
+# TODO-62: Server-side SSE helpers (broadcaster, per-event flush, heartbeat)
+
+Source: split out of TODO-18 brainstorm on 2026-05-22 (the #2 pick).
+
+## Goal
+
+Build the server-side ergonomic layer on top of the SSE marshaller landed in
9.5.0 (`FINISHED-46-juneau-marshall-sse.md`). Today `@RestGet Stream<SseEvent>`
works end-to-end (per-event flush is wired in `SseSerializerSession`), but
writing a real SSE endpoint still requires hand-rolled glue: per-connection
broadcaster fan-out, named heartbeat / keepalive scheduling, and a clean
`res.sendEvent(name, data).flush()` idiom. Add:
+
+- A `SseResponseSupport` mix-in (or convenience methods on `RestResponse`)
that lets a `@RestGet` handler emit individual events without juggling `Writer`
state.
+- An `SseBroadcaster` bean that fan-outs to N subscribers from a single
producer (server-side event bus).
+- A `SseHeartbeat` scheduler (`@Bean ScheduledExecutorService`-driven) that
emits `: ping` comments at a configurable cadence so corporate proxies don't
kill idle SSE streams after 30s.
+
+End-state developer experience:
+
+```java
+@RestGet("/stream")
+public void stream(RestRequest req, RestResponse res, SseBroadcaster bus) {
+ var sub = bus.subscribe(req.getRequestId());
+ res.sse() // sets Content-Type, disables
buffering, starts heartbeat
+ .heartbeat(Duration.ofSeconds(15))
+ .sendFrom(sub); // drains events from this
subscriber until disconnect
+}
+```
+
+## Why now
+
+- The marshaller-side primitives shipped in 9.5 (`SseSerializer`, `SseParser`,
`SseEvent`, `SseEventReader`, `SseSerializerSession` with `Writer.flush()` per
event). See `FINISHED-46-juneau-marshall-sse.md`.
+- The archive plan explicitly parked the server-side ergonomic layer:
*"Returning a reactive-streams `Publisher<SseEvent>` is out of scope
(Juneau-rest has no reactive-streams plumbing in the response pipeline today)"*
— but the simpler push-from-server case is a clean follow-on.
+- `juneau-microservice` now exposes a `WritableBeanStore` (TODO-31) so a
`SseBroadcaster` registered as `@Bean` is auto-wired into resources.
+- `BasicRestServletGroup.addChild(...)` (TODO-33) makes it easy to mount an
SSE demo / health-stream child resource dynamically.
+
+## Scope
+
+**In scope (v1):**
+
+- `org.apache.juneau.rest.sse.SseResponseSupport` (or `RestResponse.sse()`
accessor) — fluent surface for `setContent-Type` to `text/event-stream`,
disable response buffering, expose `sendEvent(SseEvent)` / `sendEvent(String
name, Object data)` / `comment(String)` / `flush()` / `close()`.
+- `org.apache.juneau.rest.sse.SseBroadcaster` — pub/sub fan-out bean. Methods:
`subscribe(String id)` returns a `SseSubscription` (a `BlockingQueue<SseEvent>`
wrapper with `Iterator<SseEvent>` and `close()`); `publish(SseEvent)` enqueues
to every active subscriber; per-subscriber bounded queue with a configurable
overflow policy (default: drop-oldest with a debug log).
+- `org.apache.juneau.rest.sse.SseHeartbeat` — `ScheduledFuture`-driven `:
ping\n\n` emitter; defaults to 15s cadence; cancellable via the returned handle.
+- New `@RestGet`-friendly parameter `SseBroadcaster` / `SseSubscription`
injection through the existing `RestOpArg` SPI (sibling of the existing
`HttpServletRequestArgs`, `RestRequestArgs`).
+- Demo endpoint added under `juneau-examples/juneau-examples-rest` exercising
a broadcaster + heartbeat (the SSE-marshalling demo `SseDemoResource` is the
obvious place to grow into a broadcaster example).
+- Tests in `juneau-utest` covering: single-subscriber drain, multi-subscriber
fan-out, slow-subscriber overflow, heartbeat insertion, client-disconnect
cleanup (the writer throws — broadcaster must release the subscription).
+- Release-notes entry under `### juneau-rest-server` in the active
release-notes file; new topic page (`pages/topics/10.08.RestServerSse.md` or
similar).
+
+**Explicitly out of scope (v1):**
+
+- Reactive-Streams `Publisher<SseEvent>` return types from `@RestOp` —
orthogonal to TODO-70 (`CompletableFuture` + virtual-threads); the brainstorm
marked it as "transport-layer change, not marshalling change."
+- `Last-Event-ID` resume support (client-side concern; the bean already
carries `id`; server-side resume would need a per-resource event journal —
defer).
+- Cross-JVM broadcasting (Redis / Kafka backplane). The `SseBroadcaster` SPI
should be split into interface + in-memory impl so an external-backplane impl
can be a sibling sub-module later, but no external impl in v1.
+- Client-side SSE consumer ergonomics — `juneau-rest-client` already gets
`SseEventReader` from the marshall module; if more is wanted, file a separate
TODO.
+
+## Phased steps
+
+### Phase 0 — confirm seams (read-only)
+
+1. Re-read
`juneau-core/juneau-marshall/src/main/java/org/apache/juneau/sse/SseSerializerSession.java`
to confirm the per-event flush contract — it already calls `Writer.flush()`
per event, which is what makes the broadcaster path safe.
+2. Inspect
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/SerializedPojoProcessor.java`
to confirm the response is **not** drained / closed by the framework when a
method calls `res.flushBuffer()` and writes directly — this is the seam the
`SseResponseSupport` rides on. (Today's `SseDemoResource` proves the pattern
works.)
+3. Confirm `RestResponse.getNegotiatedWriter()` returns the same `Writer`
`SseSerializerSession` operates on — yes, via `FinishablePrintWriter`.
+
+### Phase 1 — `SseResponseSupport` (no broadcaster)
+
+1. Add the new package `org.apache.juneau.rest.sse` in `juneau-rest-server`.
Add `SseResponseSupport` with the fluent surface, plus the `RestResponse.sse()`
accessor.
+2. Add `SseHeartbeat` (a small `Runnable` that writes a comment + flushes) and
wire the optional scheduler bean — when absent, `heartbeat(Duration)` is a
no-op (no scheduler ⇒ no heartbeat).
+3. Tests:
+ - `SseResponseSupport_Test` — single-event emit, multi-event emit, comment
write, charset is UTF-8, content-type is `text/event-stream` exactly.
+ - `SseHeartbeat_Test` — heartbeat fires at the configured cadence, cancels
on `close()`.
+
+### Phase 2 — `SseBroadcaster` + arg injection
+
+1. Add `SseBroadcaster` + `SseSubscription`. Default impl is in-memory with
per-subscriber `LinkedBlockingQueue<SseEvent>` and a configurable bound
(default: 1024 events).
+2. Add `SseBroadcasterArg` / `SseSubscriptionArg` `RestOpArg` implementations
so handlers can take them as parameters.
+3. Tests:
+ - `SseBroadcaster_Test` — pub/sub fan-out, slow-subscriber overflow policy,
subscriber-disconnect cleanup, concurrent publisher / subscriber smoke.
+ - `Rest_SseBroadcaster_IT_Test` (in `juneau-utest`) — `MockRestClient`
against a `@RestGet` using the broadcaster; assert both subscribers receive
every published event in order.
+
+### Phase 3 — demo + docs
+
+1. Update `juneau-examples/juneau-examples-rest/.../SseDemoResource.java` to
demonstrate the broadcaster pattern (keep the existing `Stream<SseEvent>`
example; add a new endpoint that uses `SseBroadcaster`).
+2. New doc page `juneau-docs/pages/topics/10.08.RestServerSse.md` (slug
`RestServerSse`) covering both the simple `Stream<SseEvent>` form and the
broadcaster form. Sidebar entry.
+3. Release-notes entry under `### juneau-rest-server`.
+
+## Acceptance criteria
+
+- [ ] `RestResponse.sse()` returns an `SseResponseSupport` that sets
`Content-Type: text/event-stream`, disables response buffering, and exposes
`sendEvent(...)` / `comment(...)` / `flush()` / `close()`.
+- [ ] `SseBroadcaster.publish(event)` reaches every active subscriber, in
order, with no drops below the per-subscriber bound. Slow-subscriber overflow
drops the oldest event and logs at `DEBUG`.
+- [ ] `SseHeartbeat` at a 15s cadence inserts `: ping\n\n` between events
without corrupting the SSE stream (verified by `SseEventReader` parsing the
captured output).
+- [ ] Client disconnect → broadcaster releases the subscription within ≤ 1
heartbeat interval (no leak in long-soak test).
+- [ ] Demo endpoint in `juneau-examples-rest` is observable via `curl -N` and
shows live event delivery.
+- [ ] Coverage ≥ 90% on the new package. Full `./scripts/test.py` green.
+- [ ] Release-notes + topic page + sidebar entry shipped.
+
+## Open questions
+
+1. **Mix-in vs accessor.** `RestResponse.sse()` accessor (recommended) vs a
separate `SseRestResponse extends RestResponse` mix-in. Accessor keeps the API
surface small and avoids subclass churn.
+2. **Overflow policy default.** Drop-oldest (recommended) vs drop-newest vs
block-publisher. Drop-oldest matches what most SSE consumers expect.
+3. **Per-subscriber queue bound default.** 1024 events / ~1MB worst case.
Configurable per subscriber; configurable per broadcaster via `@Bean
SseBroadcasterConfig`.
+4. **Heartbeat cadence default.** 15s — under Nginx's default 30s idle timeout
and AWS ALB's 60s default. Configurable.
+5. **External-backplane SPI surface.** Should v1 ship `SseBroadcaster` as an
interface (recommended) or a concrete class with hooks? Interface keeps the
door open for Redis / Kafka backplanes as separate sub-modules without breaking
changes.
+6. **Naming.** `SseBroadcaster` or `SseEventBus`? Recommend `SseBroadcaster` —
closer to the spec's "broadcasting" language.
+
+## Risks
+
+- **Servlet container buffering.** Some containers buffer the response despite
`flushBuffer()`. Mitigation: the existing SSE demo proves it works in Jetty
(per `FINISHED-46-*` verification with `curl -N`); call out Tomcat behavior in
the docs if needed.
+- **Thread leak on client disconnect.** A subscriber that never reads will pin
a queue. Mitigation: per-subscriber bounded queue + a "no read in N heartbeats
⇒ evict" timer.
+- **Coupling with TODO-67 (observability).** If TODO-67 introduces
`X-Request-Id` propagation and broadcaster subscriptions are keyed by request
id, the two need to align on the id source. Recommend
`SseBroadcaster.subscribe(String id)` accepts any string — id source is the
caller's concern.
+- **Memory pressure under broadcast storms.** A 10k-subscriber broadcaster
with 1024-event queues each can balloon to 10M events × event size. Document;
configurable bound.
+
+## Related work
+
+- `todo/FINISHED-46-juneau-marshall-sse.md` — the marshalling-side SSE
primitives this TODO consumes.
+- `todo/FINISHED-31-inject-aware-microservice.md` — `WritableBeanStore`
auto-wiring for `@Bean SseBroadcaster`.
+- `todo/FINISHED-33-dynamic-rest-children.md` — useful for mounting SSE demo /
metrics-stream resources at runtime.
+- `todo/TODO-67-observability-micrometer-otel.md` (sibling) — `X-Request-Id`
is the natural broadcaster-subscription key.
+- `todo/TODO-70-async-completablefuture-virtual-threads.md` (sibling) —
`Publisher<SseEvent>` return-type support lives there, not here.
diff --git a/todo/TODO-63-openapi-3.1-emission.md
b/todo/TODO-63-openapi-3.1-emission.md
new file mode 100644
index 0000000000..d0b3eccc8d
--- /dev/null
+++ b/todo/TODO-63-openapi-3.1-emission.md
@@ -0,0 +1,127 @@
+# TODO-63: OpenAPI 3.1 emission + bundled Swagger UI / Redoc auto-mount
+
+Source: split out of TODO-18 brainstorm on 2026-05-22 (the #3 pick).
+
+## Goal
+
+Bring the unused `juneau-bean-openapi-v3` module online server-side.
`BasicSwaggerProvider` only emits Swagger v2 today; the bean module has 33
fully-populated source files (Operation, PathItem, Components, SchemaInfo, …)
sitting waiting for a generator. Land:
+
+- A new `OpenApiProvider` SPI (sibling of `SwaggerProvider`) that emits an
OpenAPI 3.1 document from the same `@Rest` / `@RestOp` metadata.
+- A `BasicOpenApiProvider` implementation that walks the `RestContext` and
produces an `OpenApi` bean, serialized via the existing
`JsonSerializer.DEFAULT`.
+- A bundled Swagger UI (or Redoc) static-resource mount, auto-attached when
`@Rest(openapi=true)` is set, exposing the spec at `/openapi.json` and the UI
at `/openapi/ui`.
+
+End-state developer experience:
+
+```java
+@Rest(path="/petstore", openapi=true)
+public class PetStoreResource extends BasicRestObjectGroup {
+ // ... @RestGet / @RestPost methods
+}
+// → GET /petstore/openapi.json → application/json (OpenAPI 3.1 doc)
+// → GET /petstore/openapi/ui → text/html (Swagger UI)
+```
+
+## Why now
+
+- `juneau-bean-openapi-v3` shipped fully built (TODO-47 cluster delivered HAL
/ JSON:API / JSON Patch but the OpenAPI v3 bean module pre-dates and is already
in tree). 33 source files, full builders, full tests.
+- Swagger v2 is **end-of-life** for the broader ecosystem (the OpenAPI
Initiative stopped maintaining it in 2021); every modern API gateway, code-gen
tool, and AI-assist plugin expects OpenAPI 3.x.
+- `BasicSwaggerProvider` is a clean clonable pattern — the new provider
follows the same shape.
+- Dynamic child mount (TODO-33) makes auto-mounting the UI resource trivial —
the spec endpoint + UI endpoint become two `addChild(...)` calls during context
build.
+
+## Scope
+
+**In scope (v1):**
+
+- New SPI `org.apache.juneau.rest.openapi.OpenApiProvider` (interface),
`BasicOpenApiProvider` (default impl), `BasicOpenApiProviderSession` (per-call
session) under `juneau-rest-server`. Cloned from `SwaggerProvider` /
`BasicSwaggerProvider` / `BasicSwaggerProviderSession`.
+- Generator maps `@Rest` → `OpenApi`, `@RestOp` → `Operation`,
`RequestBeanMeta` → `Parameter` / `RequestBodyInfo`, `ResponseBeanMeta` →
`Response`, request/response bean class → `SchemaInfo` (via
`JsonSchemaGenerator`).
+- `openapi` attribute on `@Rest` (and per-op on `@RestGet`/`@RestPost`/etc):
when `true`, the provider auto-emits the spec doc at `/openapi.json` and
(optionally) the UI at `/openapi/ui`.
+- Bundled Swagger UI static resources (HTML + CSS + JS) added under
`juneau-rest-server/src/main/resources/htdocs/openapi-ui/`. Pulled from the
upstream `swagger-ui-dist` npm package and committed as static files (do not
add a build-time dep on `swagger-ui-dist`). Mounted via `BasicStaticFiles`.
**Locked decision needed: Swagger UI vs Redoc vs both** — see Open Question #1.
+- Tests in `juneau-utest`: `OpenApiProvider_Test` (generator),
`Rest_OpenApi_Annotation_Test` (end-to-end with `MockRestClient` — assert
`/openapi.json` returns a parseable OpenAPI 3.1 doc).
+- Release-notes entry under `### juneau-rest-server`; new topic page
(`pages/topics/14.10.RestServerOpenApi.md` or sibling slot).
+
+**Explicitly out of scope (v1):**
+
+- Removing or deprecating the existing `BasicSwaggerProvider` (Swagger v2).
Both providers coexist; Swagger v2 stays as the default for backwards
compatibility.
+- OpenAPI 3.1 client code-gen (a code-gen tool that consumes the spec and
produces `@Remote`-annotated interfaces). Worth doing eventually; orthogonal
scope.
+- Schema dialect bridging beyond what `JsonSchemaGenerator` already supports.
OpenAPI 3.1 aligns with JSON Schema 2020-12; `JsonSchemaGenerator` emits
draft-04-ish — accept lossy mapping in v1 and document the gap.
+- `webhooks` and `callbacks` sections of OpenAPI 3.1 — out for v1; emit empty.
+- Server `Components.examples` / `Components.parameters` *reuse*
(DRY-via-references). v1 emits inline; reference deduplication is a later
optimization.
+
+## Phased steps
+
+### Phase 0 — clone the seam (read-only)
+
+1. Re-read
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/swagger/{SwaggerProvider,BasicSwaggerProvider,BasicSwaggerProviderSession}.java`
to capture the SPI shape, builder pattern, and bean-store wiring.
+2. Re-read
`juneau-bean/juneau-bean-openapi-v3/src/main/java/org/apache/juneau/bean/openapi3/OpenApi.java`
(and the top-level builder `OpenApiBuilder`) to confirm the target type's
shape.
+3. Inventory the `JsonSchemaGenerator` API and identify the smallest possible
glue from `BeanInfo` / `ClassMeta` to `OpenApi.SchemaInfo`.
+4. Decide UI choice (Swagger UI vs Redoc) — see Open Question #1.
+
+### Phase 1 — `OpenApiProvider` SPI + `BasicOpenApiProvider`
+
+1. New package `org.apache.juneau.rest.openapi` in `juneau-rest-server`. Add
the three classes (`OpenApiProvider`, `BasicOpenApiProvider`,
`BasicOpenApiProviderSession`) mirroring the Swagger v2 trio.
+2. Register `OpenApiProvider` as a default-supplier-backed bean alongside
`SwaggerProvider` in `RestContext.createBeanStore(...)`.
+3. Implement the generator. Sources, in order:
+ - `@Rest(title, description, version, license, externalDocs, tags,
servers)` → `OpenApi.Info` + top-level fields.
+ - `@RestGet/@RestPost/etc` → `OpenApi.PathItem.Operation` entries.
+ - `@Path` / `@Query` / `@Header` / `@FormData` → `Operation.parameters[]`
via `RequestBeanMeta`.
+ - `@Content` (request body) → `Operation.requestBody` via `RequestBeanMeta`
and `JsonSchemaGenerator`.
+ - Return type → `Operation.responses["200"].content[mediaType].schema` via
`ResponseBeanMeta` + `JsonSchemaGenerator`.
+ - Status-code annotations + `@Response(code=...)` → additional
`Operation.responses` entries.
+4. Tests:
+ - `OpenApiProvider_Test` (unit) — given a synthetic `RestContext` with a
couple of operations, the emitted `OpenApi` round-trips through
`JsonSerializer.DEFAULT` / `JsonParser.DEFAULT` and contains the expected
`Path/Operation/Parameter/Response` shape.
+ - `OpenApiSchemaMapping_Test` — bean shapes (primitives, nested, arrays,
Maps, Optionals) map to expected `SchemaInfo` shapes.
+
+### Phase 2 — annotation + spec/UI mount
+
+1. Add `openapi` boolean attribute on `@Rest`. When `true`:
+ - Build the `OpenApi` doc once at context-init time (cached); expose via a
`RestChildren.addChild("/openapi.json", new OpenApiSpecResource(...))`-style
synthetic child.
+ - Build the UI static-files mount (`/openapi/ui`) — a `BasicStaticFiles`
instance rooted at `htdocs/openapi-ui/`, with a template `index.html` whose
`url` field points back at `/openapi.json`.
+2. Tests:
+ - `Rest_OpenApi_Annotation_Test` — end-to-end with `MockRestClient` — `GET
/openapi.json` returns 200 + valid OpenAPI 3.1 JSON; `GET /openapi/ui` returns
200 + HTML; UI HTML contains the correct `url` reference.
+3. Release-notes entry + topic page
(`pages/topics/14.10.RestServerOpenApi.md`) + sidebar entry.
+
+### Phase 3 — Redoc as alternative UI (optional, deferred)
+
+1. Add a second static-files bundle `htdocs/openapi-redoc/` and a
`@Rest(openapi=true, openapiUi=REDOC)` enum value.
+2. Both UIs share the same `/openapi.json` source.
+
+### Phase 4 — `Components.schemas` reuse (optional, deferred)
+
+1. Detect bean-class repeated use across operations, lift the inline schema to
`Components.schemas[BeanClassName]`, replace inline uses with `{ "$ref":
"#/components/schemas/BeanClassName" }`.
+
+## Acceptance criteria
+
+- [ ] `BasicOpenApiProvider` emits an `OpenApi` bean that, when serialized to
JSON, conforms to OpenAPI 3.1.0 (validated against the official schema; use the
`OpenApi` bean's own round-trip as the smoke test in v1).
+- [ ] All `@Rest` / `@RestOp` metadata that `BasicSwaggerProvider` extracts
also appears in the `OpenApi` output (parity check).
+- [ ] `@Rest(openapi=true)` end-to-end through `MockRestClient`: `GET
/openapi.json` returns the spec; `GET /openapi/ui` returns Swagger UI; the UI's
`url` reference resolves.
+- [ ] Existing `BasicSwaggerProvider` Swagger v2 behavior unchanged when
`openapi=false` (default).
+- [ ] Coverage ≥ 85% on `BasicOpenApiProvider` + `BasicOpenApiProviderSession`
(a touch lower than other bars; the generator has many switch-on-type branches
that are exhaustively but lightly exercised). Bean-side
`juneau-bean-openapi-v3` retains its existing 100% coverage.
+- [ ] Release-notes entry + topic page + sidebar entry.
+- [ ] Full `./scripts/test.py` green.
+
+## Open questions (need user direction before Phase 1)
+
+1. **UI bundle: Swagger UI vs Redoc vs both.** Recommend Swagger UI in v1
(more familiar, bigger ecosystem); Redoc as Phase 3. Alternative: ship both
side-by-side from the start. Cost: ~2MB of static files committed per UI.
+2. **Bundle distribution mechanism.** Commit the UI bundle as static files in
`juneau-rest-server/src/main/resources/htdocs/openapi-ui/` (recommended — no
build-time dep), or pull `swagger-ui-dist` at build time? Static-commit keeps
the build hermetic and licensing explicit.
+3. **Schema dialect mapping fidelity.** OpenAPI 3.1 uses JSON Schema 2020-12
(`$dynamicRef`, `unevaluatedProperties`, `prefixItems`, `if/then/else`).
`JsonSchemaGenerator` emits draft-04. v1 accepts lossy mapping (document the
gap) or invests in a 3.1-aware generator. Recommend lossy + document.
+4. **One provider vs two.** Should the new `OpenApiProvider` *replace*
`SwaggerProvider` (with Swagger v2 emission as a configurable mode) or live
alongside it (recommended for v1)? Replacement is cleaner long-term but a
larger break.
+5. **Auto-mount paths.** `/openapi.json` + `/openapi/ui` (recommended) or
configurable via `@Rest(openapiSpecPath=..., openapiUiPath=...)`? Recommend
defaults + configurable attributes.
+6. **YAML output.** OpenAPI specs are commonly served as YAML. Today Juneau
has a YAML serializer (`juneau-marshall` `YamlSerializer`); serving
`/openapi.yaml` is a one-line add. Recommend ship in v1.
+7. **Versioning the spec endpoint.** Should `/openapi.json` include the
OpenAPI version in the URL (`/openapi/v3.1/spec`)? Recommend no — single
endpoint, version is in the doc.
+8. **Coexistence with `SwaggerResource`.** Today `BasicRestObjectGroup`
exposes Swagger v2 at the `?Swagger` query param. Should `?OpenApi` query param
mirror this? Recommend yes — same convention.
+
+## Risks
+
+- **Generator complexity.** OpenAPI 3.1 has many edge cases (polymorphic
schemas via `discriminator`, `oneOf`/`anyOf`/`allOf`, callbacks, links). v1
should aim for ~80% coverage of `@Rest`/`@RestOp` features and explicitly defer
the rest.
+- **Schema-dialect mismatch.** `JsonSchemaGenerator` emits draft-04-ish;
OpenAPI 3.1 wants 2020-12. Spec-validators may flag the difference. Mitigation:
document the gap; provide a config flag to opt out of the strict 3.1 dialect
declaration if needed.
+- **UI bundle staleness.** Swagger UI ships frequent releases. Committing the
static bundle means manual updates. Mitigation: document a "how to refresh the
UI bundle" runbook; pin the upstream version in a `VERSION.txt` in the resource
dir.
+- **Spec-doc cache invalidation.** The doc is built once at context-init.
Dynamic child resources (TODO-33) added at runtime won't appear in the cached
doc until next restart. Mitigation: a `RestChildren.onMutate` callback
invalidates the cache; document the behavior.
+- **Coupling with TODO-20 (Rest Debug Rethink).** Debug-mode info should not
leak into OpenAPI output. Low risk — the providers are read-only over
`RestContext` metadata.
+
+## Related work
+
+- `todo/FINISHED-47-additional-bean-modules.md` — landed HAL / JSON:API / JSON
Patch bean modules; `juneau-bean-openapi-v3` (already in tree) is the next bean
module to grow a generator on top of.
+- `todo/FINISHED-33-dynamic-rest-children.md` — used to auto-mount
`/openapi.json` + `/openapi/ui` at context-init time.
+- `todo/FINISHED-31-inject-aware-microservice.md` — bean-store registration
for `OpenApiProvider`.
+- `todo/FINISHED-40-remove-hc45-from-rest-common-and-server.md` — clean
retyping that the generator's parameter mapping rides on.
+- Sibling: `BasicSwaggerProvider` in
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/swagger/`
— the literal template for the new code.
diff --git a/todo/TODO-64-etag-conditional-get-helpers.md
b/todo/TODO-64-etag-conditional-get-helpers.md
new file mode 100644
index 0000000000..2926a99782
--- /dev/null
+++ b/todo/TODO-64-etag-conditional-get-helpers.md
@@ -0,0 +1,97 @@
+# TODO-64: Conditional-GET / ETag / `If-Modified-Since` helpers on
`RestResponse`
+
+Source: split out of TODO-18 brainstorm on 2026-05-22.
+
+## Goal
+
+Add convenience methods on `RestResponse` (plus a small helper on
`RestRequest`) for conditional-GET handling: `eTag(String)`,
`lastModified(Instant)`, and short-circuit helpers that translate
`If-None-Match` / `If-Modified-Since` / `If-Match` / `If-Unmodified-Since`
request headers into the appropriate `304 Not Modified` or `412 Precondition
Failed` response without the handler having to write the check by hand.
+
+Today there is no `RestResponse.eTag(String)` / `lastModified(Instant)` /
`notModifiedIfMatch(...)` builder; users hand-roll. The matching exception
types (`NotModified` / `PreconditionFailed`) already live in
`org.apache.juneau.http.response`, and the `RequestHeaderList` already exposes
`If-Match` / `If-None-Match` / `If-Modified-Since` / `If-Unmodified-Since` —
only the response-side ergonomic surface is missing.
+
+End-state developer experience:
+
+```java
+@RestGet("/{id}")
+public Order get(@Path long id, RestRequest req, RestResponse res) {
+ var order = repo.find(id);
+ var tag = "\"" + order.version() + "\"";
+ res.eTag(tag).lastModified(order.updated());
+ req.checkPreconditions().orElseThrow(); // → throws NotModified /
PreconditionFailed if appropriate
+ return order;
+}
+```
+
+## Why now
+
+- `juneau-rest-server` was decoupled from `org.apache.http.*` in TODO-40
(`FINISHED-40-remove-hc45-from-rest-common-and-server.md`), and `EntityTag` /
`EntityTags` moved out of the `.classic.*` package in TODO-42
(`FINISHED-42-split-rest-common-classic.md`) — so the transport-neutral header
types are stable and reachable from `juneau-rest-server` without dragging in
`juneau-rest-common-classic`.
+- The exception types `NotModified` (304) and `PreconditionFailed` (412)
gained the full fluent-setter surface in TODO-40 and are ready to throw.
+- The 9.5 hard-break window closed all builder migrations; this is the kind of
additive ergonomics polish that fits cleanly post-9.5.
+
+## Scope
+
+**In scope (v1):**
+
+- `RestResponse.eTag(String)` / `RestResponse.eTag(EntityTag)` — sets the
`ETag` response header.
+- `RestResponse.lastModified(Instant)` /
`RestResponse.lastModified(ZonedDateTime)` — sets the `Last-Modified` response
header in RFC 7231 IMF-fixdate format.
+- `RestResponse.cacheControl(String)` and
`RestResponse.cacheControl(CacheControlBuilder)` — convenience for the
`Cache-Control` response header (e.g. `public, max-age=3600`).
+- `RestRequest.checkPreconditions()` — returns an `Optional<HttpException>`:
empty if the response should proceed; a `NotModified` if `If-None-Match`
matches the current `ETag` (or `If-Modified-Since` is satisfied); a
`PreconditionFailed` if `If-Match` / `If-Unmodified-Since` is violated.
+- Tests in `juneau-utest` covering each combination per RFC 7232.
+
+**Explicitly out of scope (v1):**
+
+- A response-cache layer (server-side caching of computed responses). This is
just the conditional-GET wire layer.
+- Weak vs strong ETag policy: `EntityTag` already models both (`isWeak()`),
and the helpers honor whatever the caller produced.
+- `Vary` header automation — caller still sets `Vary` explicitly.
+- `If-Range` for partial-content (`206 Partial Content`) — defer to a separate
range-request TODO.
+
+## Phased steps
+
+### Phase 0 — confirm seams (read-only)
+
+1. Verify `RequestHeaderList` exposes the four conditional headers — yes
(`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/httppart/RequestHeaderList.java`).
+2. Verify `EntityTag` / `EntityTags` are in `org.apache.juneau.http.header`
(transport-neutral, post TODO-42) — yes.
+3. Verify `NotModified` (`org.apache.juneau.http.response.NotModified`) and
`PreconditionFailed` are ready to throw from a handler.
+
+### Phase 1 — response-side setters
+
+1. Add `eTag(String)`, `eTag(EntityTag)`, `lastModified(Instant)`,
`lastModified(ZonedDateTime)`, `cacheControl(String)` to `RestResponse`. Each
returns `RestResponse` for chaining.
+2. Tests: `RestResponse_EtagHelpers_Test` (header values formatted per RFC
7231).
+
+### Phase 2 — request-side `checkPreconditions()`
+
+1. Add `checkPreconditions()` returning `Optional<HttpException>`.
Implementation per RFC 7232 §6 ordering: `If-Match` → `If-Unmodified-Since` →
`If-None-Match` → `If-Modified-Since`.
+2. The check reads from the response's *already-set* `ETag` / `Last-Modified`
headers — so the handler sets those first, then calls `checkPreconditions()`.
Document this ordering.
+3. Tests: `RestRequest_CheckPreconditions_Test` covering all 16 combinations
of the 4 headers, plus the weak/strong ETag matching rules.
+
+### Phase 3 — docs + release notes
+
+1. Release-notes entry under `### juneau-rest-server`.
+2. New doc page (or section in an existing page) covering the typical "ETag
round-trip" pattern.
+
+## Acceptance criteria
+
+- [ ]
`RestResponse.eTag("\"v1\"").lastModified(Instant.parse("2026-05-22T00:00:00Z"))`
sets `ETag: "v1"` and `Last-Modified: Fri, 22 May 2026 00:00:00 GMT`.
+- [ ] `checkPreconditions()` returns a `NotModified` Optional when the
client's `If-None-Match` matches the response's `ETag`.
+- [ ] `checkPreconditions()` returns a `PreconditionFailed` Optional when
`If-Match` is set and does *not* match the response's `ETag`.
+- [ ] Weak vs strong ETag matching follows RFC 7232 §2.3.2 (`If-None-Match`
allows weak match; `If-Match` requires strong match).
+- [ ] All 16 conditional-header combinations have an explicit test.
+- [ ] Coverage ≥ 95% on the new methods. Full `./scripts/test.py` green.
+
+## Open questions
+
+1. **Method placement.** Add the helpers directly on `RestResponse`
(recommended — matches the existing `addHeader` / `setHeader` / `downloadAs`
convenience style) vs a separate `ConditionalResponse` mix-in. Recommend direct.
+2. **`checkPreconditions()` return shape.** `Optional<HttpException>`
(recommended) vs `void` that throws directly vs `boolean` + caller throws.
Optional gives the caller the choice to throw or to handle inline.
+3. **Auto-derived `Last-Modified` from `Instant`.** Should accept `Date` too
for legacy bean models, or just `Instant` / `ZonedDateTime`? Recommend
`Instant` + `ZonedDateTime`; `Date` users convert explicitly.
+4. **`Cache-Control` builder.** Ship a typed `CacheControlBuilder`
(recommended — covers `public` / `private` / `max-age` / `no-cache` /
`no-store` / `must-revalidate`) or rely on the string form? Builder reduces
typo risk.
+
+## Risks
+
+- **Header-format edge cases.** `ETag` quoting (strong: `"v1"`; weak:
`W/"v1"`) and `Last-Modified` IMF-fixdate formatting have many wrong-way-around
traps. Mitigation: use `EntityTag.of(...)` / `Http*Header` formatting via the
existing `juneau-rest-common` paths.
+- **Ordering coupling.** `checkPreconditions()` must see the `ETag` /
`Last-Modified` the handler intends to send. If the handler sets them after
`checkPreconditions()`, the check uses stale values. Mitigation: javadoc
clearly documents the order.
+- **Servlet container's own conditional handling.** Some containers (notably
Jetty) short-circuit conditional GETs themselves. Verify this doesn't
double-fire. Mitigation: smoke test against Jetty in `juneau-utest`.
+
+## Related work
+
+- `todo/FINISHED-40-remove-hc45-from-rest-common-and-server.md` — gave
`BasicHttpException` / `NotModified` / `PreconditionFailed` the fluent-setter
surface.
+- `todo/FINISHED-42-split-rest-common-classic.md` — moved `EntityTag` /
`EntityTags` to the transport-neutral package.
+- `todo/TODO-61-rfc7807-server-side-wiring.md` (sibling) —
`PreconditionFailed` thrown from `checkPreconditions()` should flow through the
Problem-Details processor cleanly.
diff --git a/todo/TODO-65-health-readiness-liveness-probes.md
b/todo/TODO-65-health-readiness-liveness-probes.md
new file mode 100644
index 0000000000..9d04d643ce
--- /dev/null
+++ b/todo/TODO-65-health-readiness-liveness-probes.md
@@ -0,0 +1,109 @@
+# TODO-65: Health / readiness / liveness probe endpoints + `HealthIndicator`
SPI
+
+Source: split out of TODO-18 brainstorm on 2026-05-22.
+
+## Goal
+
+Add standard Kubernetes-style probe endpoints out of the box: a
`BasicHealthResource` opt-in that mounts `/healthz`, `/readyz`, `/livez` (or
whatever paths the user prefers), aggregating status from any number of
`HealthIndicator` beans pulled from the bean store. Each indicator reports `UP`
/ `DOWN` / `UNKNOWN` plus optional structured details; the resource composes
them into a single response and sets the HTTP status (`200 OK` if all `UP`,
`503 Service Unavailable` if any `DOWN`).
+
+End-state developer experience:
+
+```java
+// User config class
+@Configuration
+public class AppConfig {
+ @Bean
+ HealthIndicator dbHealth(DataSource ds) {
+ return () -> {
+ try (var c = ds.getConnection()) {
+ return Health.up("db").detail("validationQueryMs", 12).build();
+ } catch (SQLException e) {
+ return Health.down("db", e).build();
+ }
+ };
+ }
+}
+
+// In the microservice bootstrap
+Microservice.create()
+ .configurations(JettyConfiguration.class, AppConfig.class,
HealthProbeConfiguration.class)
+ .build().start();
+// → GET /healthz →
{"status":"UP","components":{"db":{"status":"UP","details":{"validationQueryMs":12}}}}
+// → 503 if any component is DOWN.
+```
+
+## Why now
+
+- `juneau-microservice` now exposes a `WritableBeanStore` populated from
`@Configuration` (TODO-31, `FINISHED-31-inject-aware-microservice.md`);
`getBeansOfType(HealthIndicator.class)` is a one-liner.
+- `BasicRestObjectGroup.addChild(...)` (TODO-33,
`FINISHED-33-dynamic-rest-children.md`) makes the probe resource dynamically
mountable — no static `@Rest(children=...)` ceremony required.
+- Every container deployment expects these endpoints; today users hand-roll
one.
+- Pairs naturally with TODO-67 (observability) — Micrometer's
`HealthIndicator` interface is the obvious shape to model on.
+
+## Scope
+
+**In scope (v1):**
+
+- `org.apache.juneau.rest.health.HealthIndicator` SPI (single method `Health
check()`).
+- `Health` value object (status + name + details map + optional throwable).
Static builders: `Health.up(name)`, `Health.down(name, throwable)`,
`Health.unknown(name)`.
+- `BasicHealthResource` — a `BasicRestObject` subclass with three `@RestGet`
methods (`/healthz`, `/readyz`, `/livez`). All three aggregate from the bean
store; the difference is the indicator filter (see Open Question #2).
+- `HealthProbeConfiguration` — a `@Configuration` class that contributes the
`BasicHealthResource` as a `@Bean Servlet` so the Jetty auto-mount machinery
picks it up.
+- Tests in `juneau-utest`: aggregator semantics, status-code mapping,
structured-details serialization.
+
+**Explicitly out of scope (v1):**
+
+- Pull-style /push-style metrics (that's TODO-67).
+- Spring Boot Actuator-style discovery of `/actuator/*` siblings (`/info`,
`/env`, `/loggers`, `/threads`). The probe endpoints are the minimum viable
surface; broader actuator parity is a follow-on.
+- Persistent health history. Each call evaluates fresh.
+- Caching of indicator results across probes. (DB indicators may want this;
let the indicator implementer do it themselves — keep the framework dumb.)
+
+## Phased steps
+
+### Phase 0 — confirm seams (read-only)
+
+1. Confirm `BasicRestObject` / `BasicRestObjectGroup` is the right base for
the probe resource — yes, has `@RestGet` ergonomics and bean-store access.
+2. Confirm `WritableBeanStore.getBeansOfType(HealthIndicator.class)` returns
the right shape — yes (TODO-24).
+
+### Phase 1 — SPI + value object + resource
+
+1. Add the SPI + `Health` + builders.
+2. Add `BasicHealthResource` with the three `@RestGet` methods.
+3. Add `HealthProbeConfiguration` so users get the probes by just adding the
`@Configuration` class to `Microservice.Builder.configurations(...)`.
+4. Tests:
+ - `HealthIndicator_Test` — aggregator returns `UP` when all `UP`, `DOWN`
when any `DOWN`, status code matches.
+ - `BasicHealthResource_Test` — `MockRestClient` against a resource with two
indicators (one `UP`, one `DOWN`) returns 503 with the expected body shape.
+
+### Phase 2 — docs + release notes
+
+1. Release-notes entry under `### juneau-rest-server` + `###
juneau-microservice-jetty` (for `HealthProbeConfiguration`).
+2. New doc page (`pages/topics/14.11.HealthProbes.md` or sibling slot).
+
+## Acceptance criteria
+
+- [ ] `HealthIndicator` SPI is a single-method functional interface; `@Bean
HealthIndicator` registrations are auto-discovered via
`BeanStore.getBeansOfType(...)`.
+- [ ] `/healthz` returns 200 + `{"status":"UP",...}` when all indicators are
`UP`.
+- [ ] `/healthz` returns 503 + `{"status":"DOWN","components":{...}}` when any
indicator is `DOWN`.
+- [ ] `/livez` and `/readyz` operate on indicator subsets (see Open Question
#2 for the filter mechanism).
+- [ ] `HealthProbeConfiguration` adds the resource without the user touching
`@Rest(children=...)`.
+- [ ] Coverage ≥ 95%. Full `./scripts/test.py` green.
+
+## Open questions
+
+1. **Status-code policy.** `503` on any `DOWN` (recommended — k8s convention)
vs always-200 with the structured body carrying the status. K8s probes match on
HTTP status, so 503-on-down is correct.
+2. **Live vs ready filtering mechanism.** Three options: (a) tag indicators
with a `Set<Probe>` (`LIVE`, `READY`, `STARTUP`) — recommended; (b) separate
`LivenessIndicator` / `ReadinessIndicator` interfaces; (c) one indicator, run
for all three probes. Option (a) keeps the SPI surface single.
+3. **Response format.** Recommend the Spring Boot Actuator-style `{status,
components: {name: {status, details}}}` JSON. Alternative: custom JSON, or
content-negotiated XML/JSON.
+4. **Auto-include the resource by default?** Recommend opt-in via
`HealthProbeConfiguration` (matches the `@Configuration` pattern). Auto-include
via `juneau-microservice-core` would be a behavioral change for existing users.
+5. **Probe path defaults.** `/healthz`, `/readyz`, `/livez` (k8s convention,
recommended) vs `/health`, `/ready`, `/live` vs `/actuator/health` (Spring
convention). Make configurable; default to `/healthz` etc.
+
+## Risks
+
+- **Probe latency.** A slow indicator (e.g. DB query) stalls the probe; k8s
may mark the pod unhealthy spuriously. Mitigation: a per-indicator timeout
(configurable; default 1s) wrapping the check call.
+- **Indicator throws unchecked exception.** The aggregator catches `Throwable`
and converts to `DOWN` with the throwable in `details.error`.
+- **Concurrent probe storms.** Multiple probes hitting expensive indicators
simultaneously. Mitigation: document; indicator implementer can add a
`CompletableFuture`-cached value if needed.
+
+## Related work
+
+- `todo/FINISHED-31-inject-aware-microservice.md` — `WritableBeanStore` makes
`getBeansOfType(HealthIndicator.class)` trivial.
+- `todo/FINISHED-33-dynamic-rest-children.md` — dynamic mount of the probe
resource without `@Rest(children=...)` ceremony.
+- `todo/FINISHED-36-jetty-as-bean.md` — `@Bean Servlet` auto-mount picks up
the probe resource at `@Rest(path=...)`.
+- `todo/TODO-67-observability-micrometer-otel.md` (sibling) — Micrometer's
`HealthIndicator` interface is the reference shape; if TODO-67 wires
Micrometer, the indicators could bridge to its registry.
+- `todo/TODO-66-rate-limit-and-request-id.md` (sibling) — probe paths should
be exempt from rate-limit guards by default.
diff --git a/todo/TODO-66-rate-limit-and-request-id.md
b/todo/TODO-66-rate-limit-and-request-id.md
new file mode 100644
index 0000000000..656316d651
--- /dev/null
+++ b/todo/TODO-66-rate-limit-and-request-id.md
@@ -0,0 +1,117 @@
+# TODO-66: Rate-limit guard + request-id propagation filter
+
+Source: split out of TODO-18 brainstorm on 2026-05-22.
+
+## Goal
+
+Two small, additive primitives that every production REST server eventually
needs:
+
+1. **`RateLimitGuard`** — a token-bucket `RestGuard` implementation that
throttles requests per a configurable key (IP, principal, header value),
returning `429 Too Many Requests` with a `Retry-After` header when the bucket
is empty.
+2. **`RequestIdFilter`** — a `@RestStartCall`-friendly hook that mints (or
propagates) an `X-Request-Id` header, stashes it on `RequestAttributes` so
downstream code and the call logger can pick it up, and echoes it back on the
response.
+
+Both are small enough to ship in the same TODO; both have zero new external
dependencies.
+
+End-state developer experience:
+
+```java
+@Rest(path="/api")
+public class ApiResource {
+
+ // Request id auto-minted, echoed in response header, available in MDC for
logging.
+ @Bean RequestIdFilter requestIds() { return
RequestIdFilter.create().build(); }
+
+ // Per-IP rate limit, 100 req/min, 200-burst.
+ @Bean(name="guards") RestGuardList rateLimits() {
+ return RestGuardList.of(RateLimitGuard.create()
+ .permitsPerMinute(100)
+ .burst(200)
+ .keyBy(req -> req.getRemoteAddr())
+ .build());
+ }
+}
+```
+
+## Why now
+
+- `RestGuard` SPI is mature (today carries `RoleBasedRestGuard` only — the
surface is well-defined).
+- `RequestAttributes` (separated from session properties in 9.5 per the 9.5
migration guide) is the right home for the request-id stash; the call-logger
rework planned in TODO-20 will read from `RequestAttributes` and surface the id
in its `DebugFormat` output.
+- `429` and `Retry-After` are already supported via
`org.apache.juneau.http.response.TooManyRequests` and the `Retry-After` header
in `juneau-rest-common`.
+- Zero new deps, zero behavioral risk if not registered.
+
+## Scope
+
+**In scope (v1):**
+
+- `org.apache.juneau.rest.guard.RateLimitGuard` extending `RestGuard`.
Builder-pattern config: `permitsPerSecond` / `permitsPerMinute` /
`permitsPerHour`, `burst`, `keyBy(Function<RestRequest,String>)`,
`whenLimitExceeded(BiConsumer<RestRequest, RateLimitInfo>)` callback for
logging hooks.
+- In-memory token bucket per key, evicting idle keys after a configurable TTL
(default 1 hour). `ConcurrentHashMap<String, Bucket>` with size cap (default
100k entries) — exceeding the cap triggers LRU eviction.
+- `org.apache.juneau.rest.filter.RequestIdFilter` — minted via
`UUID.randomUUID()` (configurable; can swap in a `Supplier<String>`). Honors an
incoming `X-Request-Id` if present and well-formed. Echoes on response as
`X-Request-Id`. Stashes on `RequestAttributes` under key `requestId`.
+- `RequestId` request-attribute key constant in `RestServerConstants` so
call-logger / observability layers have a single source of truth.
+- Tests in `juneau-utest`: rate-limit happy path, burst, key isolation,
eviction, `Retry-After` header value; request-id mint, propagation, response
echo.
+
+**Explicitly out of scope (v1):**
+
+- Distributed rate-limiting (Redis-backed). The SPI is split into an interface
+ in-memory impl so a `RedisRateLimitBackend` can be a sub-module later.
+- Sliding-window rate-limit algorithms. Token bucket is enough.
+- `CSRF` protection — separate concern, separate TODO if requested.
+- API-key authentication — that's TODO-69 (`AuthN guards`).
+- Custom rate-limit headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`,
`X-RateLimit-Reset`). Emit them; document that they're advisory and
unstandardized. Worth shipping in v1 — adds ~10 LOC.
+
+## Phased steps
+
+### Phase 0 — confirm seams (read-only)
+
+1. `RestGuard.guard(RestRequest, RestResponse)` signature — confirmed as the
right hook for rate-limit (runs before the handler).
+2. `RequestAttributes` write access in `@RestStartCall` — confirmed.
+3. `TooManyRequests` constructor accepts a body and supports `setHeader(...)`
for `Retry-After` — confirmed via TODO-40's exception-surface uplift.
+
+### Phase 1 — `RateLimitGuard`
+
+1. Add the class, builder, `Bucket` internal type, eviction policy.
+2. Tests:
+ - `RateLimitGuard_Test` — burst tokens drain, refill at the configured
rate, `429 + Retry-After` thrown when empty.
+ - `RateLimitGuard_KeyIsolation_Test` — different keys have independent
buckets.
+ - `RateLimitGuard_Eviction_Test` — buckets idle > TTL get evicted.
+
+### Phase 2 — `RequestIdFilter`
+
+1. Add the class + `RequestId` constant.
+2. Tests:
+ - `RequestIdFilter_Test` — mint when absent; honor when present +
well-formed; reject and re-mint when present but malformed.
+ - `RequestIdFilter_Echo_Test` — response `X-Request-Id` matches the request
attribute.
+
+### Phase 3 — docs + release notes
+
+1. Release-notes entry under `### juneau-rest-server` for both.
+2. New doc page or section: "Rate-limiting and request-id propagation."
+
+## Acceptance criteria
+
+- [ ] `RateLimitGuard` with `permitsPerMinute(100).burst(200).keyBy(req ->
req.getRemoteAddr())` allows 200 immediate requests, then throttles to 100/min
per IP.
+- [ ] `429 Too Many Requests` is thrown when the bucket is empty, with a
`Retry-After` header set to the seconds-until-next-token.
+- [ ] `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
response headers are populated on every response that passes through the guard.
+- [ ] `RequestIdFilter` mints a UUID when no `X-Request-Id` is present; echoes
it on the response; stashes it on `RequestAttributes` under key `requestId`.
+- [ ] Incoming `X-Request-Id` that fails validation (configurable predicate;
default: matches `^[A-Za-z0-9-_]{1,128}$`) is replaced with a freshly-minted
one.
+- [ ] Coverage ≥ 95% on both classes. Full `./scripts/test.py` green.
+
+## Open questions
+
+1. **Default key.** `req.getRemoteAddr()` (IP-based) is the obvious default.
Alternative: header-based (`X-Forwarded-For` aware). Recommend IP-based with
`XForwardedFor`-aware as a builder flag.
+2. **Storage SPI.** Ship `RateLimitGuard.Storage` interface in v1 (recommended
— keeps Redis-backed impl as a sub-module later) or land monolithically and
refactor later? Interface is small (`tryAcquire(key, permits)` + `eviction`),
recommend ship now.
+3. **Validation predicate for `X-Request-Id`.** Default
`^[A-Za-z0-9-_]{1,128}$` (recommended — UUIDs + most distributed-tracing ids).
Configurable.
+4. **`Retry-After` value when bucket is empty.** Seconds until one token is
available (recommended) vs HTTP-date. Seconds is simpler and more common.
+5. **Probe-path exemption.** Should `/healthz`/`/readyz`/`/livez` (TODO-65) be
exempted from rate-limits by default? Recommend yes if both TODOs land — add a
`.exemptPaths(String...)` builder method that ships with sensible defaults.
+
+## Risks
+
+- **Memory growth under attack.** A spammer with rotating IPs can fill the
bucket map. Mitigation: size cap + LRU eviction (already in scope).
+- **Clock-source dependency.** Token-bucket math uses `System.nanoTime()`;
safe vs wall-clock jumps but harder to debug. Document.
+- **Distributed-deploy footgun.** Per-pod buckets ≠ per-cluster buckets. Users
running N replicas need a distributed backend. Document loudly; flag the
`Storage` SPI as the extension point.
+- **`X-Forwarded-For` spoofing.** If used without a trusted proxy, attackers
can defeat the IP-based key. Document; require explicit opt-in.
+
+## Related work
+
+- `todo/FINISHED-40-remove-hc45-from-rest-common-and-server.md` —
`TooManyRequests` exception type gained the fluent-setter surface this needs.
+- `todo/TODO-20-rest-debug-rethink.md` — call-logger should pick up the
request-id from `RequestAttributes` and render it in `DebugFormat` output.
+- `todo/TODO-65-health-readiness-liveness-probes.md` (sibling) — probe paths
should be exempt from rate-limit by default.
+- `todo/TODO-67-observability-micrometer-otel.md` (sibling) — request-id
should propagate into OTel span attributes and Micrometer tags.
+- `todo/TODO-69-authn-guards-jwt-apikey.md` (sibling) — different concern
(authentication), often paired with rate-limit in the guard chain.
diff --git a/todo/TODO-67-observability-micrometer-otel.md
b/todo/TODO-67-observability-micrometer-otel.md
new file mode 100644
index 0000000000..4d350e8b53
--- /dev/null
+++ b/todo/TODO-67-observability-micrometer-otel.md
@@ -0,0 +1,120 @@
+# TODO-67: Observability hooks — Micrometer + OpenTelemetry seams via
`MethodExecStats`
+
+Source: split out of TODO-18 brainstorm on 2026-05-22.
+
+## Goal
+
+Expose the existing per-method execution statistics (`MethodExecStats` /
`RestContextStats`) through pluggable observability backends, so a Juneau REST
server can drop into Prometheus / OpenTelemetry pipelines with no hand-rolled
instrumentation.
+
+Two complementary surfaces, each in its own opt-in sub-module:
+
+1. **`juneau-rest-server-micrometer`** — a `MetricsRecorder` that bridges
`MethodExecStats` counters/timers into a `MeterRegistry` (Prometheus, StatsD,
JMX, whatever).
+2. **`juneau-rest-server-otel`** — an OpenTelemetry tracer hook that creates a
span per request, populates standard HTTP attributes (`http.request.method`,
`http.response.status_code`, `http.route`), and propagates the `traceparent` /
`tracestate` headers.
+
+End-state developer experience:
+
+```java
+// pom: add juneau-rest-server-micrometer + your preferred MeterRegistry (e.g.
micrometer-registry-prometheus).
+@Configuration
+public class ObservabilityConfig {
+ @Bean MeterRegistry registry() { return new
PrometheusMeterRegistry(PrometheusConfig.DEFAULT); }
+ @Bean MetricsRecorder recorder(MeterRegistry r) { return new
MicrometerMetricsRecorder(r); }
+}
+// → MethodExecStats events automatically fan out to the registry; scrape via
/actuator/prometheus.
+
+// pom: add juneau-rest-server-otel + OTel SDK.
+@Bean OpenTelemetry otel() { return GlobalOpenTelemetry.get(); }
+@Bean TracerHook hook(OpenTelemetry otel) { return new OtelTracerHook(otel); }
+// → Each request becomes a span; W3C trace context propagates in/out.
+```
+
+## Why now
+
+- `MethodExecStats` and `RestContextStats` already track per-method runs /
avgTime / maxTime / errors. The data is there — only the wiring is missing.
+- `RestStartCall` / `RestEndCall` hooks are the right SPI surface for span
creation / closure; both are stable and already used by `BasicCallLogger`.
+- TODO-31 made `WritableBeanStore` first-class on the microservice path, so a
`@Bean MeterRegistry` flows through automatically.
+- TODO-66 (sibling) introduces `RequestIdFilter`; the request id is the
natural span attribute / log correlation id.
+- TODO-20 (rest debug rethink) is moving call-logging to a structured
`DebugFormat` SPI — both efforts want a cleaner observability boundary, so
getting this in early reduces churn.
+
+## Scope
+
+**In scope (v1):**
+
+- New SPI in `juneau-rest-server`:
`org.apache.juneau.rest.metrics.MetricsRecorder` (interface) — `record(String
opName, Duration elapsed, int statusCode, Throwable maybeError)`. Default
`NoOpMetricsRecorder` registered by default. `MethodExecStats` invokes the
configured recorder at the end of each call.
+- New SPI in `juneau-rest-server`: `org.apache.juneau.rest.tracing.TracerHook`
(interface) — `Scope startSpan(RestRequest req)` returning an `AutoCloseable`
`Scope`; the framework invokes `Scope.close()` in `RestEndCall`. Default
`NoOpTracerHook`.
+- New sub-module **`juneau-rest-server-micrometer`** in `juneau-rest/`:
`MicrometerMetricsRecorder` impl; opt-in dep on `io.micrometer:micrometer-core`
(provided scope).
+- New sub-module **`juneau-rest-server-otel`** in `juneau-rest/`:
`OtelTracerHook` impl + `W3CTracePropagator` for in/out propagation; opt-in dep
on `io.opentelemetry:opentelemetry-api` (provided scope).
+- W3C `traceparent` / `tracestate` header in/out propagation (in the OTel
sub-module) so distributed-tracing context survives.
+- Tests in `juneau-utest`: SPI contract tests with a
`RecordingMetricsRecorder` / `RecordingTracerHook`. Module-local tests in each
sub-module verify the Micrometer / OTel bridge.
+
+**Explicitly out of scope (v1):**
+
+- Structured-logging bridges (SLF4J / Log4j2 structured appender) — TODO-20
owns the call-logger rework; the OTel sub-module can publish a `Logs` event
later if requested.
+- Custom tag schemes per resource. v1 uses fixed OTel HTTP semantic-convention
attribute names.
+- Histogram percentile config from annotations — let the user configure the
`MeterRegistry`.
+- StatsD / Datadog / NewRelic native bridges. Use Micrometer's registries.
+
+## Phased steps
+
+### Phase 0 — confirm seams (read-only)
+
+1. `MethodExecStats.add(...)` (or whatever the per-call invocation hook is) —
confirm it's reachable from a recorder. Inspect
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/stats/MethodExecStats.java`
and `MethodInvoker.java`.
+2. `RestStartCall` / `RestEndCall` annotation invocation timing — confirm
`RestEndCall` runs even on exception paths.
+3. `RestRequest.getRequestId()` (or its equivalent if TODO-66 doesn't land
first) — for span correlation. If TODO-66 hasn't landed, OTel hook generates
its own span id and stashes on `RequestAttributes`.
+
+### Phase 1 — SPI seams in `juneau-rest-server`
+
+1. Add `MetricsRecorder` + `NoOpMetricsRecorder`. Wire into `MethodExecStats`
end-of-call.
+2. Add `TracerHook` + `NoOpTracerHook`. Wire into `RestStartCall` /
`RestEndCall`.
+3. Tests: `MetricsRecorder_Contract_Test`, `TracerHook_Contract_Test` using a
recording impl.
+
+### Phase 2 — Micrometer sub-module
+
+1. Create `juneau-rest/juneau-rest-server-micrometer/`. `pom.xml` mirrors
`juneau-rest-server-mcp` (closest sibling — small, single-purpose, opt-in
module).
+2. `MicrometerMetricsRecorder` translates `(opName, elapsed, statusCode,
error)` →
`Timer.builder("http.server.requests").tags(...).register(registry).record(elapsed)`.
+3. Tests verify Prometheus scrape output via
`PrometheusMeterRegistry.scrape()`.
+
+### Phase 3 — OTel sub-module
+
+1. Create `juneau-rest/juneau-rest-server-otel/`. Same pom shape.
+2. `OtelTracerHook` creates a span per request, sets standard HTTP attributes,
propagates W3C context in/out. `TextMapPropagator` for in/out header carrier.
+3. Tests verify span creation + W3C header round-trip.
+
+### Phase 4 — docs + release notes
+
+1. Release-notes entries under `### juneau-rest-server`, `###
juneau-rest-server-micrometer (new module)`, `### juneau-rest-server-otel (new
module)`.
+2. Two new doc pages (one per sub-module).
+
+## Acceptance criteria
+
+- [ ] `MetricsRecorder` SPI receives one event per `@RestOp` call with
operation name, elapsed time, status code, and optional throwable.
+- [ ] `TracerHook` SPI receives `startSpan` / `Scope.close` exactly once per
call (including error paths).
+- [ ] `MicrometerMetricsRecorder` registers a `Timer` named
`http.server.requests` with tags `{method, uri, status, exception}` matching
Spring Boot's convention (eases scrape-config reuse).
+- [ ] `OtelTracerHook` produces a span with `http.request.method`,
`http.response.status_code`, `http.route` attributes per OTel HTTP semantic
conventions.
+- [ ] Incoming `traceparent` header continues an existing trace; outgoing
`traceparent` propagates the trace id to downstream services.
+- [ ] Each sub-module's pom uses `provided` scope on its external dep so the
user pulls the version they want.
+- [ ] Coverage ≥ 90% on the new SPI in `juneau-rest-server`; ≥ 85% on each
sub-module. Full `./scripts/test.py` green.
+
+## Open questions
+
+1. **Module placement.** Two sub-modules under `juneau-rest/` (recommended —
matches `juneau-rest-server-mcp` precedent) vs one combined
`juneau-rest-server-observability` module. Two modules keep deps isolated.
+2. **Metric naming convention.** Spring Boot's `http.server.requests` +
`{method, uri, status, exception}` tags (recommended — wide tooling support) vs
OTel-native `http.server.duration` + `{http.request.method,
http.response.status_code, ...}`. Recommend Spring-style for Micrometer
(matches existing dashboards); use OTel-native names only in the OTel
sub-module.
+3. **Tag cardinality for `uri`.** Raw URI is high-cardinality (each
`/users/123`, `/users/124`, … is unique). Use the `@RestOp` template path
(`/users/{id}`) as the `uri` tag — recommend. Requires `RestContext` lookup at
recording time.
+4. **`MetricsRecorder` vs direct `MeterRegistry` bean.** The SPI indirection
lets a user swap Micrometer for any other backend (Dropwizard Metrics, custom).
Recommend keep the SPI; the bridge is the only Micrometer-dependent class.
+5. **OTel `Tracer` source.** Use `GlobalOpenTelemetry.get()` (recommended —
standard practice) or require an injected `Tracer`? Both supported; the
Configuration class picks.
+6. **Logging-bridge (OTel Logs).** Out of scope for v1 — confirm.
+
+## Risks
+
+- **Recorder overhead on hot path.** Default `NoOpMetricsRecorder` must
short-circuit cleanly (no allocations). Mitigation: enforce via JMH
micro-benchmark in the SPI test.
+- **Spec drift.** OTel semantic conventions are still evolving (`http.method`
→ `http.request.method` happened in 2023). Pin to a stable version range and
document.
+- **Memory growth from tag cardinality.** A misconfigured tag (raw URI, raw
user agent) blows up the metrics registry. Document; default to route-template
URIs.
+- **Cross-cutting overlap with TODO-20 (debug rethink).** The new
`DebugFormat` SPI in TODO-20 may want to read the OTel span id for log
correlation. Recommend: TODO-67 stashes `traceId` / `spanId` on
`RequestAttributes` under stable keys so TODO-20's formatter can read them.
+
+## Related work
+
+- `todo/FINISHED-31-inject-aware-microservice.md` — `WritableBeanStore` flows
`@Bean MeterRegistry` / `@Bean OpenTelemetry` automatically.
+- `todo/TODO-20-rest-debug-rethink.md` — call-logger rework should coordinate
on tag / id sharing.
+- `todo/TODO-66-rate-limit-and-request-id.md` (sibling) — `requestId` from
TODO-66 becomes the log-correlation key alongside the OTel span id.
+- `todo/TODO-65-health-readiness-liveness-probes.md` (sibling) — Micrometer's
`HealthIndicator` could bridge to the TODO-65 SPI (a `MicrometerHealthBridge`
adapter, optional).
+- Existing: `MethodExecStats` / `RestContextStats` / `ThrownStats` in
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/stats/` —
the data sources this TODO consumes.
diff --git a/todo/TODO-68-bean-validation-integration.md
b/todo/TODO-68-bean-validation-integration.md
new file mode 100644
index 0000000000..c1038ab1db
--- /dev/null
+++ b/todo/TODO-68-bean-validation-integration.md
@@ -0,0 +1,114 @@
+# TODO-68: Bean Validation (Jakarta Validation 3.x) integration on request
beans
+
+Source: split out of TODO-18 brainstorm on 2026-05-22.
+
+## Goal
+
+Honor Jakarta Validation constraints (`@NotNull`, `@Email`, `@Size`, `@Min`,
`@Max`, custom validators, validation groups) on `@Content` / `@FormData` /
`@Request`-bound beans in `@RestOp` handler signatures. When constraint
violations are found, fail-fast with a `400 Bad Request` (or, if TODO-61 has
landed and `problemDetails=true` is set, a `400 application/problem+json` with
the standard `errors[]` extension).
+
+Today `HttpPartSchema` already participates in a
`HttpPartSchema_JakartaValidation_Test` (so the dependency is on the test
classpath and the imports compile), but request-bean validation is *not* wired
into the handler arg pipeline — a `@Valid` annotation on a request bean is
silently ignored.
+
+End-state developer experience:
+
+```java
+public class OrderRequest {
+ @NotBlank String customerId;
+ @NotNull @Positive Integer quantity;
+ @Email String contactEmail;
+}
+
+@RestPost("/orders")
+public Order create(@Content @Valid OrderRequest in) {
+ // If validation fails, the handler is never called.
+ // Response is 400 + JSON body listing the violations.
+ return orderService.create(in);
+}
+```
+
+## Why now
+
+- Spring Boot users universally expect this to work. Adopting Juneau today
means writing validation-by-hand or accepting silent-pass-through.
+- `HttpPartSchema_JakartaValidation_Test` already in `juneau-utest` proves the
dep can be added (provided scope) without polluting downstream consumers.
+- TODO-24 (`FINISHED-24-jsr330-and-spring-lite-support.md`) established the
FQN-based annotation-recognition pattern (Juneau recognizes
`jakarta.inject.Inject` / Spring `@Autowired` etc. by fully-qualified name
without taking a hard dep on either). The same pattern works for
`jakarta.validation.Valid` / `jakarta.validation.constraints.*`.
+- Pairs naturally with TODO-61 — `ConstraintViolationException` →
`Problem.errors[]` extension is the standard pattern.
+
+## Scope
+
+**In scope (v1):**
+
+- Detection (by FQN) of `jakarta.validation.Valid` and
`jakarta.validation.constraints.*` on `@Content` / `@FormData` /
`@Request`-bound parameters in `@RestOp` handlers.
+- Lookup of a `Validator` bean from the bean store (optional — if absent,
`jakarta.validation.Validation.buildDefaultValidatorFactory().getValidator()`
provides the default).
+- New arg-handler enhancement in `ContentArg` / `RequestBeanArg` /
`FormDataArg` that, after binding, invokes `validator.validate(bean,
groups...)` when a `@Valid` (or constraint) annotation is present.
+- New `org.apache.juneau.rest.validation.ValidationException` (extends
`BasicHttpException` with status 400) carrying the set of violations.
+- A response-side renderer that:
+ - With TODO-61's Problem-Details processor active: produces
`application/problem+json` with `errors: [{field, message, invalidValue}]`
extension.
+ - Without TODO-61: produces a simple JSON body `{ "errors": [{field,
message}], "status": 400 }`.
+- Tests in `juneau-utest` covering primitive constraints (`@NotNull`, `@Size`,
`@Email`), nested-bean validation, validation groups, custom validators.
+
+**Explicitly out of scope (v1):**
+
+- Method-level validation (`@Validated` on the resource class, constraints on
`@RestOp` return values). Defer.
+- Schema-driven validation (e.g. JSON Schema constraints on the request body).
Out of scope; that lives in `JsonSchemaGenerator` territory.
+- Validation message localization beyond what Jakarta Validation natively does
via `ValidationMessages.properties`.
+- Custom `ConstraintValidator` discovery beyond what `jakarta.validation`
already does.
+- Cross-field validation via custom annotations — works automatically as long
as the user writes their own `ConstraintValidator`.
+
+## Phased steps
+
+### Phase 0 — confirm seams (read-only)
+
+1. Re-read `HttpPartSchema_JakartaValidation_Test` to see what's already
exercised and what dep is on the test path.
+2. Inspect `ContentArg` / `RequestBeanArg` / `FormDataArg` in
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/arg/` to
find the "after bind, before call" seam.
+3. Decide whether `Validator` is constructed lazily per-call or eagerly
per-`RestContext` (recommend eager, cached on `RestContext`).
+
+### Phase 1 — SPI + arg integration
+
+1. Add `org.apache.juneau.rest.validation.ValidationException`,
`ValidationViolation` (small record: `field`, `message`, `invalidValue`).
+2. Add a `Validator` bean-store lookup in `RestContext.createBeanStore(...)`
with a default supplier that builds one from
`Validation.buildDefaultValidatorFactory()`. Lazy via `Memoizer` so the
validator isn't constructed if no `@Valid` is ever encountered.
+3. Modify `ContentArg`, `RequestBeanArg`, `FormDataArg` to check for `@Valid`
(FQN match) post-bind, invoke `validator.validate(...)`, and throw
`ValidationException` on violations.
+4. Tests:
+ - `BeanValidation_Content_Test` — `@Content @Valid` failure → 400.
+ - `BeanValidation_NestedBean_Test` — nested `@Valid` on a property cascades.
+ - `BeanValidation_Groups_Test` — `@Valid` with a `Group.class` constraint
marker.
+ - `BeanValidation_CustomValidator_Test` — user `ConstraintValidator` is
honored.
+
+### Phase 2 — Problem-Details integration
+
+1. If TODO-61's `ProblemDetailsProcessor` is in the chain, route
`ValidationException` through it with the `errors[]` extension (matches
Spring's `MethodArgumentNotValidException` mapper shape).
+2. Tests:
+ - `BeanValidation_ProblemDetails_Test` — `@Rest(problemDetails=true)`
end-to-end: violation → `application/problem+json` with `errors[]`.
+
+### Phase 3 — docs + release notes
+
+1. Release-notes entry under `### juneau-rest-server`.
+2. New doc page (or section in an existing page) walking through the `@Valid`
flow and the response shapes.
+
+## Acceptance criteria
+
+- [ ] `@Content @Valid MyBean` on a handler parameter validates the bound
bean; constraint violations fail-fast with `400 Bad Request` before the handler
is invoked.
+- [ ] Constraint annotations are recognized by FQN — no hard compile-time dep
on Jakarta Validation from `juneau-rest-server`'s `pom.xml`.
+- [ ] If `jakarta.validation-api` is *not* on the runtime classpath, `@Valid`
is silently ignored (graceful degradation) — confirmed by a
"no-jakarta-validation-on-classpath" test path.
+- [ ] Default `Validator` is built from
`Validation.buildDefaultValidatorFactory()`; users can override by registering
a `@Bean Validator`.
+- [ ] When TODO-61's `ProblemDetailsProcessor` is active and
`@Rest(problemDetails=true)` is set, violations render as
`application/problem+json` with the `errors[]` extension.
+- [ ] Coverage ≥ 90% on the new validation classes. Full `./scripts/test.py`
green.
+
+## Open questions
+
+1. **Hard vs optional dep on Jakarta Validation.** Recommend optional
(`provided` scope), FQN-based detection, graceful degradation when absent
(matches the TODO-24 pattern). Alternative: hard dep — simpler, but pulls
Jakarta Validation onto every consumer.
+2. **Validation groups annotation source.** Spring uses `@Validated` for
groups. Juneau could either honor `@Validated` (FQN) or extend `@Valid` to
accept a `groups()` attribute — but the latter requires a Juneau-owned `@Valid`
clone. Recommend honor Spring's `@Validated` (FQN-based) for groups; let
`jakarta.validation.Valid` mean "default group."
+3. **Default response shape without Problem-Details.** `{ "errors": [...],
"status": 400 }` (recommended) vs `{ "violations": [...] }`. Match Spring's
shape where possible.
+4. **`invalidValue` in the violation payload.** Include by default
(recommended) or omit (privacy-sensitive)? Include but document that handlers
can replace the renderer if the value is sensitive.
+5. **`ValidationException` placement.** Under
`org.apache.juneau.rest.validation` (recommended) vs
`org.apache.juneau.http.response` (alongside other 4xx exceptions). Recommend
the dedicated package — keeps validation concerns grouped.
+
+## Risks
+
+- **Classpath fragility.** Users on Jakarta Validation 2.x
(`javax.validation.*`) won't be honored. Mitigation: detect both FQN families
(`jakarta.validation.Valid` and `javax.validation.Valid`); document.
+- **Performance.** Validator construction is expensive; cache on
`RestContext`. Validation of large nested graphs is O(n); document.
+- **Error-message localization.** Jakarta Validation reads
`ValidationMessages.properties`; if the user has Juneau `Messages` + a
different bundle, the two don't compose. Document; out of scope to bridge.
+- **Cross-cutting overlap with TODO-61.** The `errors[]` shape becomes a
contract. Mitigation: pick the shape once (Spring-compatible) and document it
as part of the Problem-Details extension surface.
+
+## Related work
+
+- `todo/FINISHED-24-jsr330-and-spring-lite-support.md` — established the
FQN-based recognition pattern this TODO reuses.
+- `todo/TODO-61-rfc7807-server-side-wiring.md` (sibling) — `Problem.errors[]`
shape is shared.
+- Existing test:
`juneau-utest/src/test/java/org/apache/juneau/httppart/HttpPartSchema_JakartaValidation_Test.java`
— proves the Jakarta Validation dep can coexist with the existing build.
diff --git a/todo/TODO-69-authn-guards-jwt-apikey.md
b/todo/TODO-69-authn-guards-jwt-apikey.md
new file mode 100644
index 0000000000..c663b832c8
--- /dev/null
+++ b/todo/TODO-69-authn-guards-jwt-apikey.md
@@ -0,0 +1,125 @@
+# TODO-69: AuthN guards — `BearerTokenGuard`, `ApiKeyGuard`, optional JWT
verification
+
+Source: split out of TODO-18 brainstorm on 2026-05-22.
+
+## Goal
+
+Add canonical authentication guards on top of the existing `RestGuard` SPI
(today carries `RoleBasedRestGuard` for AuthZ but nothing for AuthN). Provide:
+
+- **`BearerTokenGuard`** — extracts `Authorization: Bearer <token>` and
validates via a pluggable `TokenValidator` SPI.
+- **`ApiKeyGuard`** — extracts an API key from a configurable header / query
param / cookie and validates against a `Map<String,Principal>` or a pluggable
`ApiKeyStore` SPI.
+- **`JwtTokenValidator`** (in an opt-in sub-module `juneau-rest-server-jwt`) —
JWT verification (HS256 / RS256 / ES256) against a configurable JWKS URL or
static key. Builds on `nimbus-jose-jwt` as an optional dep so the core stays
lean.
+- An auto-injected `@Auth Principal` arg so handlers can read the
authenticated principal without `RestRequest.getUserPrincipal()` boilerplate.
+
+End-state developer experience:
+
+```java
+@Rest(path="/api")
+public class ApiResource {
+
+ @Bean(name="guards") RestGuardList auth() {
+ return RestGuardList.of(
+ BearerTokenGuard.create()
+ .validator(JwtTokenValidator.create()
+ .jwksUrl("https://auth.example.com/.well-known/jwks.json")
+ .audience("api.example.com")
+ .build())
+ .build());
+ }
+
+ @RestGet("/me")
+ public Profile me(@Auth Principal p) { // injected, non-null guaranteed
+ return profileService.lookup(p.getName());
+ }
+}
+```
+
+## Why now
+
+- `RoleBasedRestGuard` covers AuthZ but the AuthN gap forces every Juneau user
to wrap Spring Security or roll their own `Filter`. The framework should have a
first-class story.
+- `RestGuard.guard(req, res)` SPI is stable and proven.
+- TODO-24's FQN-based annotation recognition pattern lets `@Auth` work without
forcing a Juneau-owned `Principal` type — `java.security.Principal` is fine.
+- TODO-66 (rate limit + request id) is the obvious adjacent guard; both
compose cleanly in `RestGuardList`.
+
+## Scope
+
+**In scope (v1):**
+
+- `org.apache.juneau.rest.auth.TokenValidator` SPI — single method `Principal
validate(String token) throws AuthenticationException`.
+- `org.apache.juneau.rest.auth.BearerTokenGuard extends RestGuard` — extracts
`Authorization: Bearer <token>`, delegates to `TokenValidator`, stashes the
resulting `Principal` on `RequestAttributes` under key `principal`, throws `401
Unauthorized` (with `WWW-Authenticate: Bearer realm=...`) on failure.
+- `org.apache.juneau.rest.auth.ApiKeyStore` SPI — `Optional<Principal>
lookup(String key)`.
+- `org.apache.juneau.rest.auth.ApiKeyGuard extends RestGuard` — extracts from
header (default `X-API-Key`) / query / cookie, delegates to `ApiKeyStore`, same
stash + 401 behavior.
+- `org.apache.juneau.rest.auth.AuthArg implements RestOpArg` — resolves `@Auth
Principal` (or any `Principal`-typed parameter) from the request attribute.
+- `org.apache.juneau.rest.auth.AuthenticationException extends
BasicHttpException` (status 401, with `WWW-Authenticate` header support).
+- New sub-module **`juneau-rest-server-jwt`** in `juneau-rest/`:
`JwtTokenValidator` impl with JWKS-URL fetching (cached per RFC 7517 §4.5),
audience / issuer / clock-skew validation, RS256 / ES256 / HS256 algorithm
support. Optional dep on `com.nimbusds:nimbus-jose-jwt` (provided scope).
+- Tests in `juneau-utest`: bearer-token happy path, missing header → 401,
invalid token → 401, principal injection; API-key happy path; same for JWT
module (in its own `juneau-rest-server-jwt` test or under `juneau-utest`).
+
+**Explicitly out of scope (v1):**
+
+- Basic auth (`Authorization: Basic`). Easy to add later if requested.
+- OAuth 2.0 client-credentials flow (issuing tokens). This is AuthN only —
verify, don't issue.
+- mTLS — separate transport-layer concern.
+- Session-based auth (cookies + server-side session store).
+- Custom auth providers beyond the `TokenValidator` / `ApiKeyStore` SPIs.
+- Spring Security bridge (let the Spring user keep using Spring Security; the
SPIs above are for non-Spring users).
+
+## Phased steps
+
+### Phase 0 — confirm seams (read-only)
+
+1. `RestGuard.guard(RestRequest, RestResponse)` return-or-throw contract —
confirmed.
+2. `Unauthorized` exception type
(`org.apache.juneau.http.response.Unauthorized`) supports
`setHeader("WWW-Authenticate", ...)` via the TODO-40 fluent surface — confirmed.
+3. `RequestAttributes` write access from `RestGuard` — confirmed.
+
+### Phase 1 — `BearerTokenGuard` + `ApiKeyGuard` + `AuthArg`
+
+1. Add the SPIs + guards + arg-resolver.
+2. Add `AuthenticationException` + `Principal` stash key constant in
`RestServerConstants`.
+3. Tests:
+ - `BearerTokenGuard_Test` — happy path, missing header, malformed header,
validator-rejects.
+ - `ApiKeyGuard_Test` — header / query / cookie sources, unknown key → 401.
+ - `AuthArg_Test` — `@Auth Principal` injected; `Principal` parameter
without `@Auth` also resolves.
+
+### Phase 2 — `juneau-rest-server-jwt` sub-module
+
+1. New module `juneau-rest/juneau-rest-server-jwt/` with pom mirroring
`juneau-rest-server-mcp`. Dep on `com.nimbusds:nimbus-jose-jwt:9.40` (or
current; confirm at land time) in `provided` scope.
+2. `JwtTokenValidator` impl: JWKS fetch + cache (5-min default TTL); validate
`iss`, `aud`, `exp`, `nbf` with configurable clock-skew tolerance (default
60s); algorithm pin (default RS256).
+3. Tests verify JWKS rotation, expired token rejection, audience mismatch
rejection, algorithm-confusion attack rejection (HS256 token presented to an
RS256-configured validator).
+
+### Phase 3 — docs + release notes
+
+1. Release-notes entries under `### juneau-rest-server` and `###
juneau-rest-server-jwt (new module)`.
+2. New doc page (or section) walking through bearer + API-key + JWT flows.
+
+## Acceptance criteria
+
+- [ ] `BearerTokenGuard` extracts `Authorization: Bearer <token>`, delegates
to the configured `TokenValidator`, stashes the resulting `Principal` on
`RequestAttributes`, throws 401 with `WWW-Authenticate: Bearer realm=...` on
any failure.
+- [ ] `@Auth Principal p` parameter resolves to the stashed principal; null is
never injected (guard runs first).
+- [ ] `ApiKeyGuard` extracts from a configurable source (header / query /
cookie) and validates against the configured `ApiKeyStore`.
+- [ ] `JwtTokenValidator` (in the new sub-module) validates a JWT against a
JWKS URL with caching, honors `iss` / `aud` / `exp` / `nbf` / clock-skew, and
rejects algorithm-confusion attacks.
+- [ ] No new compile-time deps in `juneau-rest-server` (only in
`juneau-rest-server-jwt`).
+- [ ] Coverage ≥ 90% on the core guards; ≥ 85% on the JWT sub-module
(network-bound paths mocked). Full `./scripts/test.py` green.
+
+## Open questions
+
+1. **JWT library choice.** `nimbus-jose-jwt` (recommended — battle-tested,
minimal transitive deps) vs `jjwt` (less battle-tested) vs the JDK's own
`java.security.spec` primitives (write-it-yourself, error-prone). Recommend
nimbus.
+2. **JWKS cache TTL default.** 5 minutes (recommended) vs 1 hour vs honoring
HTTP `Cache-Control` from the JWKS endpoint. Recommend 5 minutes with manual
override.
+3. **Algorithm allowlist.** Default `[RS256, ES256]` (recommended; reject
HS256 unless explicitly opted-in to prevent algorithm-confusion). Configurable.
+4. **`@Auth` annotation name.** `@Auth` (recommended — short, unambiguous) vs
`@Principal` (clashes with `java.security.Principal`) vs `@AuthenticatedUser`.
+5. **Auto-register a default `RestGuardList`?** No — require explicit
`@Bean(name="guards") RestGuardList`. Auto-registration is a footgun (silently
enables auth on resources that don't expect it).
+6. **`Principal` subtype.** Use `java.security.Principal` (recommended — JDK
standard) vs a Juneau-owned `AuthenticatedPrincipal` carrying claims. Recommend
JDK `Principal`; ship a `ClaimsPrincipal extends Principal` for
token-with-claims callers who want structured access.
+
+## Risks
+
+- **Auth bugs are security bugs.** Misconfigured JWT validators (accepting
`none`, missing `aud` check, expired clock) are CVE-class. Mitigation: secure
defaults (algorithm allowlist, mandatory `aud` check, 60s clock-skew cap),
explicit deprecation warnings if users opt into risky configs, exhaustive test
matrix against known attack patterns.
+- **JWKS endpoint availability.** A down JWKS endpoint stalls every request.
Mitigation: cache + graceful-degradation fallback (serve cached keys past TTL
on fetch failure, log loudly).
+- **Replay attacks.** JWT alone doesn't prevent replay. Document; recommend
pairing with TODO-66's rate limit + short token lifetimes.
+- **Multiple guards in `RestGuardList` ordering.** Auth must run before
rate-limit (otherwise unauthenticated traffic uses the same bucket as
authenticated). Document; ship a `RestGuardList.standardOrder(...)` helper that
orders guards correctly.
+
+## Related work
+
+- `todo/FINISHED-40-remove-hc45-from-rest-common-and-server.md` —
`Unauthorized` exception fluent-setter surface this needs.
+- `todo/FINISHED-24-jsr330-and-spring-lite-support.md` — FQN-based recognition
pattern for `@Auth`.
+- `todo/TODO-66-rate-limit-and-request-id.md` (sibling) — composes in the same
`RestGuardList`; ordering matters.
+- `todo/TODO-61-rfc7807-server-side-wiring.md` (sibling) —
`AuthenticationException` should render as `application/problem+json` when
problem-details is on.
+- Existing: `RoleBasedRestGuard` — sibling guard for AuthZ; the AuthN guards
stash a `Principal` that role-based guards can then check against.
diff --git a/todo/TODO-70-async-completablefuture-virtual-threads.md
b/todo/TODO-70-async-completablefuture-virtual-threads.md
new file mode 100644
index 0000000000..cd4d640639
--- /dev/null
+++ b/todo/TODO-70-async-completablefuture-virtual-threads.md
@@ -0,0 +1,119 @@
+# TODO-70: `CompletableFuture<?>` return-type support + optional
virtual-thread per-request dispatch
+
+Source: split out of TODO-18 brainstorm on 2026-05-22.
+
+## Goal
+
+Unblock asynchronous and long-running I/O handlers in two complementary ways:
+
+1. **`CompletableFuture` / `CompletionStage` return-type support.** A new
`AsyncResponseProcessor` slotted ahead of `SerializedPojoProcessor` unwraps
`CompletableFuture` / `CompletionStage` / `Future` return values, bridges to
the servlet's `AsyncContext`, and feeds the completed value into the existing
serialization pipeline once ready. Handlers can return
`CompletableFuture<Order>` and the framework handles the dispatch.
+2. **Virtual-thread per-request dispatch (Java 21+, opt-in).** A
`@Rest(virtualThreads=true)` flag (or a `@Bean ExecutorService` matched by a
well-known name) causes the resource's dispatcher to hand each call off to a
virtual thread via `Thread.ofVirtual().factory()`, removing the
thread-per-request blocking constraint.
+
+End-state developer experience:
+
+```java
+@Rest(path="/orders", virtualThreads=true)
+public class OrderResource {
+
+ @RestGet("/{id}")
+ public CompletableFuture<Order> get(@Path long id) {
+ return orderService.lookupAsync(id); // resource thread is not
blocked
+ }
+}
+```
+
+## Why now
+
+- `Stream<SseEvent>` return-type support landed via TODO-46
(`FINISHED-46-juneau-marshall-sse.md`), and that plan explicitly parked
`Publisher<SseEvent>` and `CompletableFuture` as out-of-scope follow-ons:
*"Returning a reactive-streams `Publisher<SseEvent>` is out of scope
(Juneau-rest has no reactive-streams plumbing in the response pipeline today)."*
+- Java 21 (virtual threads, stable since LTS) is widely deployed; the Juneau
project floor is currently Java 17 but the virtual-thread path can be an opt-in
code path that compiles under 17 and runs only on 21+ via reflective
`Thread.ofVirtual()` invocation.
+- `ResponseProcessor` chain is the right seam — `SerializedPojoProcessor` is
the existing reference for "unwrap something, hand it to a serializer."
+- `AsyncContext` is mature Servlet 3.1+ API; the framework already runs on
Jakarta Servlet.
+
+## Scope
+
+**In scope (v1):**
+
+- `org.apache.juneau.rest.processor.AsyncResponseProcessor` — slotted into the
default `ResponseProcessorList` ahead of `SerializedPojoProcessor`. Detects
`CompletableFuture` / `CompletionStage` / `Future` return values, starts
servlet `AsyncContext`, registers a completion callback that re-feeds the
unwrapped value into the rest of the chain.
+- Default timeout for async completion (configurable; default 30s) — on
timeout, write `504 Gateway Timeout` and abort the `AsyncContext`.
+- `@Rest(virtualThreads=true)` flag (and per-op equivalent). When true, the
`RestOpInvoker` dispatches handler invocation onto
`Thread.ofVirtual().factory()`-backed `Executor`. Implementation is reflective
(compile-time Java 17, runtime Java 21+ check); on Java 17 runtime the flag is
silently ignored with a warning at context-init time.
+- Tests in `juneau-utest`: `CompletableFuture<String>` return → expected body;
`CompletableFuture` that throws → expected error path; async timeout → 504;
virtual-thread dispatch confirmed via `Thread.currentThread().isVirtual()` in a
handler (skipped on Java 17 runtime).
+- Release-notes entry under `### juneau-rest-server`.
+
+**Explicitly out of scope (v1):**
+
+- Reactive-Streams `Publisher<T>` return types (Project Reactor / RxJava).
Larger surface; defer to a sibling TODO if a concrete caller emerges.
+- `Mono<T>` / `Flux<T>` direct support. Same — defer.
+- `Flow.Publisher<SseEvent>` SSE streaming (TODO-62 owns server-side SSE;
cross-coordinate when both land).
+- Async filter chain (today's `@RestPreCall` / `@RestPostCall` are
synchronous). Defer; the `AsyncResponseProcessor` only changes the *return*
path.
+- Coroutines / Kotlin `suspend` functions. Out of scope.
+- Per-request `ThreadLocal` migration — see Risks; document that handlers must
not assume thread-local persistence across the async boundary.
+
+## Phased steps
+
+### Phase 0 — confirm seams (read-only)
+
+1. Re-read
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/SerializedPojoProcessor.java`
and `ResponseProcessorList.java` to confirm the processor SPI return contract.
+2. Re-read `RestOpInvoker.java` (or whatever invokes the handler method) to
find the dispatch seam for virtual-thread switching.
+3. Confirm `AsyncContext` is started before any other write/flush to the
response — Servlet 3.1 requires this.
+4. Inspect `RequestAttributes`, `VarResolverSession`, `Locale` — what's
`ThreadLocal`-backed today vs request-scoped? (Most should be request-scoped
via `RestSession`; document any thread-local leaks.)
+
+### Phase 1 — `AsyncResponseProcessor`
+
+1. Add the class. Detects `CompletableFuture` / `CompletionStage` / `Future`
in `res.getContent(Object.class)`.
+2. Calls `req.getRequest().startAsync()`, registers `whenComplete((value,
error) -> ...)` callback that:
+ - On success: stuffs `value` back into `RestResponse.setContent(value)` and
re-invokes the response-processor chain (skipping `AsyncResponseProcessor`
itself to avoid recursion) under the async context, then `complete()`.
+ - On failure: stuffs the throwable into `RestResponse.setException(...)`,
re-invokes the chain (so `ThrowableProcessor` / `ProblemDetailsProcessor`
handle it), then `complete()`.
+3. Timeout: register an `AsyncListener.onTimeout` that writes 504 and
completes.
+4. Tests:
+ - `AsyncResponseProcessor_Test` — `CompletableFuture<String>` happy path.
+ - `AsyncResponseProcessor_Error_Test` — `CompletableFuture` that throws →
`ThrowableProcessor` handles it.
+ - `AsyncResponseProcessor_Timeout_Test` — never-completing future → 504
after the configured timeout.
+
+### Phase 2 — virtual-thread dispatch (Java 21+, opt-in)
+
+1. Add `virtualThreads` attribute to `@Rest` and the per-op annotations.
+2. At context-init, if `virtualThreads=true` and `Runtime.version().feature()
>= 21`, build a `Thread.ofVirtual().factory()`-backed `Executor` via reflection
and stash on `RestContext`. If runtime is Java 17/18/19/20, log a warning and
proceed as if `virtualThreads=false`.
+3. `RestOpInvoker` (or equivalent dispatch class) submits handler invocation
to the executor when present; falls back to direct (caller-thread) invocation
otherwise.
+4. Tests:
+ - `VirtualThreadDispatch_Test` (annotated `@DisabledOnJre(JRE.JAVA_17)` or
similar) — handler reports `Thread.currentThread().isVirtual() == true`.
+ - `VirtualThreadDispatch_Java17_Warning_Test` — context-init under Java 17
logs the configured-but-unsupported warning and proceeds.
+
+### Phase 3 — docs + release notes
+
+1. Release-notes entry under `### juneau-rest-server`.
+2. New doc page (or section) covering both flavors, with a thread-local
caveats callout.
+
+## Acceptance criteria
+
+- [ ] `@RestGet` returning `CompletableFuture<String>` sends the unwrapped
string body to the client when the future completes.
+- [ ] Same handler returning a future that fails with `NotFound` propagates
the exception through `ThrowableProcessor` and produces a 404.
+- [ ] A handler returning a future that never completes results in a 504
response after the configured timeout (default 30s).
+- [ ] `@Rest(virtualThreads=true)` on Java 21+ dispatches handler invocation
on a virtual thread.
+- [ ] `@Rest(virtualThreads=true)` on Java 17/18/19/20 logs a warning at
context-init and falls back to caller-thread dispatch — no runtime error.
+- [ ] Existing synchronous handlers (no `CompletableFuture`, no
`virtualThreads=true`) have zero behavioral change.
+- [ ] Coverage ≥ 90% on `AsyncResponseProcessor`; ≥ 85% on the virtual-thread
dispatch path (some paths skipped on Java 17 CI runs). Full `./scripts/test.py`
green on both Java 17 and Java 21 if both are available in CI.
+
+## Open questions
+
+1. **Java floor.** Stay on Java 17 for the project floor; gate virtual-threads
behind runtime detection (recommended) vs bump the floor to Java 21. Recommend
stay on 17 — bumping is a larger ecosystem decision.
+2. **Default async timeout.** 30 seconds (recommended — matches typical proxy
timeouts) vs unlimited. Configurable.
+3. **Reactor / Mono / Flux integration.** Out of scope for v1 — confirm. Could
ship as a `juneau-rest-server-reactor` sub-module later (mirror the TODO-67
pattern of opt-in sub-modules for external deps).
+4. **`Future<T>` support.** Bare `java.util.concurrent.Future` (not
`CompletableFuture`) requires polling. Recommend honor it via
`ForkJoinPool.commonPool().submit(future::get)` rather than blocking the
request thread — but it's a footgun. Alternative: reject bare `Future` and
require `CompletableFuture`. Recommend reject with a clear error message.
+5. **Thread-local handling.** `RequestAttributes` and `VarResolverSession` are
request-scoped (not `ThreadLocal`) so they survive the async boundary. Confirm
nothing else relies on `ThreadLocal` (logging MDC is the obvious one — document
the MDC contract change).
+6. **Sub-module placement.** Land the async processor in `juneau-rest-server`
directly (recommended — uses only JDK APIs) vs a sub-module. Sub-module makes
sense if we later add Reactor.
+
+## Risks
+
+- **Thread-local leakage across the async boundary.** Anything carried via
`ThreadLocal` (SLF4J MDC, security contexts, OTel scope) silently breaks when
the handler returns a `CompletableFuture` that completes on a different thread.
Mitigation: document loudly; recommend `MDC.put`-equivalents move to
`RequestAttributes`; the OTel hook in TODO-67 should use `Scope` (which
`OtelTracerHook` closes in `RestEndCall`, not bound to any specific thread).
+- **AsyncContext error-handling subtleties.** `complete()` must be called
exactly once; double-complete throws. Mitigation: atomic state machine in
`AsyncResponseProcessor`.
+- **Virtual-thread pinning.** Synchronized blocks and JNI calls pin a virtual
thread to its carrier thread, defeating the benefit. Mitigation: document;
recommend `ReentrantLock` over `synchronized` in handlers.
+- **Test flakiness.** Async tests with timeouts are easy to make flaky.
Mitigation: use `Awaitility` or equivalent for assertion polling; keep timeout
tests deterministic.
+- **Cross-cutting overlap with TODO-62 (SSE).** SSE handlers are inherently
long-running; virtual-thread dispatch is the obvious fit. Both should land
before users heavily adopt SSE.
+
+## Related work
+
+- `todo/FINISHED-46-juneau-marshall-sse.md` — explicitly parked
`Publisher<SseEvent>` and `CompletableFuture` as future work.
+- `todo/TODO-62-sse-server-helpers.md` (sibling) — server-side SSE handlers
benefit from virtual-thread dispatch; coordinate landing order.
+- `todo/TODO-67-observability-micrometer-otel.md` (sibling) — OTel `Scope`
must survive the async boundary; coordinate on the `TracerHook` lifecycle.
+- `todo/TODO-61-rfc7807-server-side-wiring.md` (sibling) — Problem-Details
rendering must work for both sync and async returns; the
`AsyncResponseProcessor` re-invokes the chain so this is automatic, but verify
in tests.
+- Existing:
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/SerializedPojoProcessor.java`
— the literal template for the new processor's serialization path.
diff --git a/todo/TODO.md b/todo/TODO.md
index aa19c00ae2..f682140e99 100644
--- a/todo/TODO.md
+++ b/todo/TODO.md
@@ -1,9 +1,29 @@
# TODO
-- [TODO-18] Investigate possible useful features to add to juneau-rest-server.
-
- [TODO-20] - Rest debug rethink.
- [TODO-35] - Beanstore test injection.
-- [TODO-37] - Agent instruction consolidation.
\ No newline at end of file
+- [TODO-37] - Agent instruction consolidation.
+
+- [TODO-61] RFC 7807 / 9457 Problem-Details server-side wiring. See
`todo/TODO-61-rfc7807-server-side-wiring.md`.
+
+- [TODO-62] Server-side SSE helpers (broadcaster, per-event flush, heartbeat).
See `todo/TODO-62-sse-server-helpers.md`.
+
+- [TODO-63] OpenAPI 3.1 emission + bundled Swagger UI / Redoc auto-mount. See
`todo/TODO-63-openapi-3.1-emission.md`.
+
+- [TODO-64] Conditional-GET / ETag / `If-Modified-Since` helpers on
`RestResponse`. See `todo/TODO-64-etag-conditional-get-helpers.md`.
+
+- [TODO-65] Health / readiness / liveness probe endpoints + `HealthIndicator`
SPI. See `todo/TODO-65-health-readiness-liveness-probes.md`.
+
+- [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`.
+
+- [TODO-69] AuthN guards — `BearerTokenGuard`, `ApiKeyGuard`, optional JWT
verification. See `todo/TODO-69-authn-guards-jwt-apikey.md`.
+
+- [TODO-70] `CompletableFuture<?>` return-type support + optional
virtual-thread per-request dispatch. See
`todo/TODO-70-async-completablefuture-virtual-threads.md`.
+
+- [TODO-71] Move doc site updates from a github hook to a script that gets
executed locally. Change docusaurus search functionality to
@easyops-cn/docusaurus-search-local.