From d8a34c57d2b028cb3ac2f73096124e0854426d23 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 12:49:45 +0200 Subject: [PATCH 01/14] docs: After-response hook design --- .../2026-05-20-after-response-hook-design.md | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-20-after-response-hook-design.md diff --git a/docs/superpowers/specs/2026-05-20-after-response-hook-design.md b/docs/superpowers/specs/2026-05-20-after-response-hook-design.md new file mode 100644 index 0000000..53b8862 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-after-response-hook-design.md @@ -0,0 +1,251 @@ +# After-Response Hook + +**Status:** Proposed +**Date:** 2026-05-20 +**Author:** thced + +## Goal + +Let library consumers register code that runs **after the HTTP response has been +written to the wire**, on the same virtual thread that handled the request, with +the library's request-scoped value still bound. Hook exceptions are swallowed so +they never affect the client (which has already received the response anyway). + +Typical uses: telemetry flushes, audit log emission, post-commit notifications, +trace-span close, latency metrics with the final status code. + +## API + +### Global hook (boot-time) + +```java +@FunctionalInterface +public interface AfterResponseHook { + void after(Request request, Response response); +} +``` + +Registered on the builder: + +```java +OpenApiServer.builder() + .afterResponseHook((req, resp) -> log.info("{} {}", req.operationId(), resp.status())) + .build(); +``` + +Multiple hooks may be registered; they run in registration order. + +### Per-request hook + +Handlers (or interceptors) queue `Runnable`s on the current request: + +```java +public final class Request { + public void afterResponse(Runnable runnable) { /* appends to internal queue */ } +} +``` + +Multiple runnables may be queued; they run FIFO. + +### Order + +Global hooks fire first (registration order), then per-request runnables (FIFO). + +### Exception policy + +Every hook invocation is wrapped in `try { ... } catch (Throwable t) { LOG.debug(...) }`. +A throwing hook does not affect other hooks, the response (already sent), or the +exchange. Errors are not propagated. + +## Execution model + +After-hooks fire on the **request virtual thread**, inside the existing +`ScopedValue.where(DispatchHandler.CURRENT, request)` binding established by +`RequestPreparationFilter`. No re-binding, no thread hand-off. + +Hooks fire after the bytes have been flushed to the client — i.e., after either +`ResponseRenderer.render(...)` returns in `DispatchHandler`, or +`ExceptionHandler.handle(...)` has written an error response. + +## Structural change + +To run hooks inside the existing scoped binding without re-binding, the +exception-handling responsibility moves from `ExceptionFilter` into +`RequestPreparationFilter`. `ExceptionFilter` is deleted. + +Filter chain before: + +``` +ExceptionFilter → RequestPreparationFilter → SecurityFilter → DispatchHandler +``` + +Filter chain after: + +``` +RequestPreparationFilter → SecurityFilter → DispatchHandler +``` + +`RequestPreparationFilter` is the single owner of the exchange. Pseudo-code: + +```java +try { + // routing + parameter/body validation (may throw NotFound/MethodNotAllowed/Validation) + Request request = build(...); + ScopedValue.where(DispatchHandler.CURRENT, request).run(() -> { + try { + chain.doFilter(exchange); // Security → Dispatch (renders or throws) + } catch (Throwable t) { + exceptionHandler.handle(exchange, t); // writes error response to exchange + } + // response is sent; scope still bound + fireAfterHooks(request, exchange); + }); +} catch (Throwable t) { + // pre-Request failure (404/405/validation): no Request, so no after-hooks + exceptionHandler.handle(exchange, t); +} +``` + +The "extras" routes registered via `Builder.extraRoute(...)` keep their own +`ExceptionFilter` wrapper — these routes have no OpenAPI Request and no +after-hook semantics. `ExceptionFilter` the class is retained and used only for +extras contexts; it is no longer added to the OpenAPI context's filter chain. + +## The `Response` object passed to hooks + +On the success path, hooks receive the exact `Response` rendered by +`DispatchHandler` (after `ResponseDecorator`s). + +On the error path, `ExceptionHandler` writes directly to the exchange and never +produces a `Response`. To keep the hook signature uniform, the framework +synthesises one after the error has been rendered: + +```java +new Response( + exchange.getResponseCode(), // 4xx/5xx from ExceptionHandler + null, // body already streamed; unavailable + exchange.getResponseHeaders().getFirst("Content-Type"), + flatten(exchange.getResponseHeaders())) // first value per header name +``` + +Hooks must therefore treat `Response.body()` as **always `null` on error paths**. +Status and headers are accurate. This is documented on `AfterResponseHook`. + +## Edge cases + +**Streaming responses.** `Response.stream(...)` writes the body via a +`StreamingBody` callback inside `ResponseRenderer`. The hook fires after the +streaming callback returns, i.e., after the last byte has been written. + +**Per-request queue when handler is missing.** `MissingOperationHandlerException` +is thrown by `DispatchHandler` after `Request` is built. The handler queue is +empty (handler never ran), so only global hooks fire. The error-path synthetic +`Response` is used. + +**Pre-Request failures (404/405/validation).** No `Request` was built. Neither +global nor per-request hooks fire. Documented limitation. + +**Hook throws.** Logged at DEBUG, swallowed. Next hook still runs. + +**`afterResponse` called after hooks have started.** The runner snapshots the +queue before invoking the first per-request runnable. Calls to +`afterResponse(...)` from inside a running hook, or from a leaked `Request` +reference held after the response has been sent, are silently ignored. The +queue stays appendable (no clear/lock) — the snapshot semantics are what +guarantee deterministic execution. Documented; not enforced at runtime. + +## Implementation outline + +### `com.retailsvc.http.AfterResponseHook` (new public) + +```java +package com.retailsvc.http; + +@FunctionalInterface +public interface AfterResponseHook { + void after(Request request, Response response); +} +``` + +### `com.retailsvc.http.Request` (modified) + +Add an internal `List` field plus: + +```java +public void afterResponse(Runnable runnable) { + Objects.requireNonNull(runnable, "runnable must not be null"); + afterHooks.add(runnable); +} +``` + +A package-private getter (`List afterHooks()`) exposes the list to +`RequestPreparationFilter`. The list is initialised to an empty mutable +`ArrayList` in the constructor. The runner snapshots the list before iterating +so runnables added during hook execution are ignored. + +The current `Request` constructor has seven parameters. Adding an eighth would +ripple through call sites; instead the field is initialised internally and +exposed only through `afterResponse(...)` and the package-private getter. + +### `com.retailsvc.http.internal.RequestPreparationFilter` (modified) + +- Constructor takes `ExceptionHandler` and `List` in addition + to its current dependencies. +- `doFilter` restructured per the pseudo-code above. +- New private helper `fireAfterHooks(Request, HttpExchange)` builds the + synthetic `Response` if needed, then invokes each hook inside a `try/catch`. + +### `com.retailsvc.http.internal.ExceptionFilter` + +- Deleted from the OpenAPI route's filter chain (folded into + `RequestPreparationFilter`). +- For `extraRoute` contexts, `OpenApiServer` continues to install an + `ExceptionFilter` (or an inline equivalent) so unhandled exceptions from + extras still flow to the user's `ExceptionHandler`. + +### `com.retailsvc.http.OpenApiServer` (modified) + +- `HandlerConfig` gains `List afterHooks`. +- The OpenAPI context registration no longer adds `ExceptionFilter` first; it + starts with the updated `RequestPreparationFilter` which is given the + `ExceptionHandler` and the after-hook list. +- `Builder.afterResponseHook(AfterResponseHook)` appends to an `ArrayList`. + +### `com.retailsvc.http.internal.ResponseRenderer` (no change expected) + +`render` already runs synchronously on the request thread. + +## Testing strategy + +Unit tests (Surefire, `*Test.java`): + +- `RequestTest`: `afterResponse(null)` throws NPE; multiple calls queue in order. +- `OpenApiServerBuilderTest` (new or extend existing): `afterResponseHook(null)` + throws NPE; multiple hooks queue in order. + +Integration tests (Failsafe, `*IT.java`) using the existing test server harness: + +- **Success path:** handler returns 200; global hook + per-request hook both fire + in order; both see the rendered status and operationId. +- **Per-request only:** no global hook registered; handler queues two runnables; + both fire FIFO. +- **Handler throws:** handler queues a runnable then throws; runnable still + fires (per-request queue is drained regardless); global hook sees synthetic + Response with the error status. +- **Hook throws:** first hook throws, second hook still runs; response to client + is unaffected. +- **Pre-Request failure:** request hits an unknown path; 404 returned; no hooks + fire (assert global counter unchanged). +- **Scoped value visibility:** hook reads `DispatchHandler.CURRENT.get()` and + gets the same `Request` instance the handler saw. +- **Thread identity:** hook captures `Thread.currentThread()` and the handler + captures the same; assert equality (same virtual thread). + +## Out of scope + +- Async / off-thread hooks: explicitly not supported. Users wanting async + behavior can submit to their own executor from inside a hook. +- Ordering across global hooks of different priorities. Insertion order only. +- Removing or de-registering hooks after `build()`. +- Hooks on `extraRoute` handlers. +- Mutating the response from a hook (impossible — bytes have been sent). From d823e7ea97d094f71e9af9981f770cb0c2fbe2b1 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 13:01:21 +0200 Subject: [PATCH 02/14] docs: After-response hook implementation plan --- .../plans/2026-05-20-after-response-hook.md | 1032 +++++++++++++++++ 1 file changed, 1032 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-20-after-response-hook.md diff --git a/docs/superpowers/plans/2026-05-20-after-response-hook.md b/docs/superpowers/plans/2026-05-20-after-response-hook.md new file mode 100644 index 0000000..f708f13 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-after-response-hook.md @@ -0,0 +1,1032 @@ +# After-Response Hook Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `AfterResponseHook` (global, builder-registered) and `Request.afterResponse(Runnable)` (per-request) hooks that run after the HTTP response has been sent — on the request virtual thread, inside the library's `ScopedValue` binding, with all exceptions ignored. + +**Architecture:** Fold the existing `ExceptionFilter`'s job into `RequestPreparationFilter` so hooks fire inside the still-bound `ScopedValue.where(DispatchHandler.CURRENT, request)` block — no thread hand-off, no scope rebinding. After-hook queues live as a mutable list shared between the original `Request` and any `withPrincipals(...)` copy. Error-path responses are synthesised from `HttpExchange` (status + headers; body null since bytes are gone). + +**Tech Stack:** Java 25, JDK `com.sun.net.httpserver`, JUnit 5, AssertJ, Mockito, Maven. + +**Spec:** `docs/superpowers/specs/2026-05-20-after-response-hook-design.md` + +--- + +## File Structure + +**Create:** +- `src/main/java/com/retailsvc/http/AfterResponseHook.java` — public functional interface. +- `src/test/java/com/retailsvc/http/AfterResponseHookIT.java` — end-to-end behavior with a live `OpenApiServer`. + +**Modify:** +- `src/main/java/com/retailsvc/http/Request.java` — add internal `List` queue, `afterResponse(Runnable)` method, package-private `afterHooks()` getter; thread the queue through `withPrincipals(...)`. +- `src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java` — take `ExceptionHandler` and `List` as constructor args; restructure `doFilter` to do its own exception handling inside the scoped block and fire hooks afterwards. +- `src/main/java/com/retailsvc/http/OpenApiServer.java` — extend `HandlerConfig` with `afterHooks`, drop `ExceptionFilter` from the OpenAPI chain (keep it for extras), pass the new deps into `RequestPreparationFilter`, add `Builder.afterResponseHook(...)`. +- `src/test/java/com/retailsvc/http/RequestTest.java` (if missing, create) — unit tests for the new queue methods. + +**Untouched but worth a glance during implementation:** +- `src/main/java/com/retailsvc/http/internal/SecurityFilter.java` — calls `withPrincipals`; verify queue propagation works through it. +- `src/main/java/com/retailsvc/http/internal/DispatchHandler.java` — renders the success-path response; no changes needed. +- `src/main/java/com/retailsvc/http/internal/ExceptionFilter.java` — stays as-is for extras contexts; no longer used on the OpenAPI chain. + +--- + +## Task 1: Add `AfterResponseHook` public interface + +**Files:** +- Create: `src/main/java/com/retailsvc/http/AfterResponseHook.java` + +- [ ] **Step 1: Create the interface** + +```java +package com.retailsvc.http; + +/** + * Callback invoked after an HTTP response has been written to the client. Runs on the same virtual + * thread that handled the request, inside the library's request {@link ScopedValue} binding. + * + *

Hooks fire only when a {@link Request} was successfully constructed — i.e., routing and + * parameter/body validation passed. Pre-request failures (404, 405, 400 validation) do not fire + * hooks. + * + *

On the error path, {@link Response#body()} is always {@code null} because the body bytes + * have already been sent. {@link Response#status()} and {@link Response#headers()} reflect what + * was written to the wire. + * + *

Exceptions thrown by a hook are logged at DEBUG and swallowed; subsequent hooks still run. + * Hooks compose in registration order. + */ +@FunctionalInterface +public interface AfterResponseHook { + + void after(Request request, Response response); +} +``` + +- [ ] **Step 2: Compile** + +Run: `mvn -q test-compile` +Expected: BUILD SUCCESS. + +- [ ] **Step 3: Commit** + +```bash +git add src/main/java/com/retailsvc/http/AfterResponseHook.java +SKIP=commitlint git commit -m "feat: Add AfterResponseHook interface" +``` + +--- + +## Task 2: Add the per-request queue to `Request` + +**Files:** +- Modify: `src/main/java/com/retailsvc/http/Request.java` +- Test: `src/test/java/com/retailsvc/http/RequestTest.java` + +The queue must survive `withPrincipals(...)` — the new instance shares the *same* `List` reference so runnables queued post-security are visible to the runner that holds the original `Request`. + +- [ ] **Step 1: Write the failing unit tests** + +Check first whether `RequestTest.java` exists: + +```bash +ls src/test/java/com/retailsvc/http/RequestTest.java 2>/dev/null && echo EXISTS || echo MISSING +``` + +If it exists, append the test methods below into the existing class. If it's missing, create the file with this scaffolding plus the methods. Statically import `org.assertj.core.api.Assertions.*` and `org.junit.jupiter.api.Assertions.assertThrows`. + +```java +package com.retailsvc.http; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class RequestTest { + + private static Request newRequest() { + return new Request( + new byte[0], + null, + null, + "op", + Map.of(), + null, + name -> null); + } + + @Test + void afterResponseRejectsNull() { + Request request = newRequest(); + assertThrows(NullPointerException.class, () -> request.afterResponse(null)); + } + + @Test + void afterResponseQueuesInOrder() { + Request request = newRequest(); + List log = new ArrayList<>(); + request.afterResponse(() -> log.add("first")); + request.afterResponse(() -> log.add("second")); + + for (Runnable r : request.afterHooks()) { + r.run(); + } + + assertThat(log).containsExactly("first", "second"); + } + + @Test + void withPrincipalsSharesAfterHookQueue() { + Request original = newRequest(); + List log = new ArrayList<>(); + original.afterResponse(() -> log.add("from-original")); + + Request enriched = original.withPrincipals(Map.of("scheme", "principal")); + enriched.afterResponse(() -> log.add("from-enriched")); + + for (Runnable r : original.afterHooks()) { + r.run(); + } + + assertThat(log).containsExactly("from-original", "from-enriched"); + assertThat(original.afterHooks()).isSameAs(enriched.afterHooks()); + } +} +``` + +- [ ] **Step 2: Run tests and confirm they fail to compile** + +Run: `mvn -q test -Dtest=RequestTest` +Expected: compilation failure (`afterResponse`, `afterHooks` not found). + +- [ ] **Step 3: Add the queue and methods to `Request`** + +Edit `src/main/java/com/retailsvc/http/Request.java`: + +1. Add the field below `private Map queryParamCache;`: + +```java + private final java.util.List afterHooks; +``` + +(Use the existing `java.util.List` import — already imported via `LinkedHashMap` path? Verify by adding `import java.util.List;` and `import java.util.ArrayList;` at the top with the other `java.util` imports.) + +2. In **both** public constructors, initialise the field. The 7-arg constructor delegates to the 8-arg one, so only the 8-arg constructor needs the assignment. Add at the end of the 8-arg constructor body: + +```java + this.afterHooks = new ArrayList<>(); +``` + +3. Add a **package-private** constructor used by `withPrincipals` to share the queue. Place it directly below the 8-arg public constructor: + +```java + // Package-private: lets withPrincipals(...) thread the after-hook queue through so that + // runnables registered on either the original Request or the principals-enriched copy + // land in the same backing list. + Request( + byte[] body, + Object parsed, + TypeMapper bodyMapper, + String operationId, + Map pathParameters, + String rawQuery, + UnaryOperator headerLookup, + Map principals, + List afterHooks) { + this.body = body; + this.parsed = parsed; + this.bodyMapper = bodyMapper; + this.operationId = operationId; + this.pathParameters = pathParameters; + this.rawQuery = rawQuery; + this.headerLookup = headerLookup; + this.principals = Map.copyOf(principals); + this.afterHooks = afterHooks; + } +``` + +Annotate it with `@SuppressWarnings("java:S107")` (same rationale as the 8-arg public ctor). + +4. Replace `withPrincipals(...)` to use the new 9-arg constructor so the queue is shared: + +```java + public Request withPrincipals(Map principals) { + return new Request( + body, + parsed, + bodyMapper, + operationId, + pathParameters, + rawQuery, + headerLookup, + principals, + afterHooks); + } +``` + +5. Add the public queue API and package-private getter at the bottom of the class (above `parseQuery`): + +```java + /** + * Queues a {@link Runnable} to execute after the HTTP response has been sent to the client. + * Runs on the request thread inside the library's request {@link ScopedValue} binding. + * Multiple calls queue FIFO. Exceptions thrown by the runnable are logged at DEBUG and + * swallowed. + * + *

Calls made after the runner has snapshotted the queue (e.g. from inside a running + * hook, or from a leaked {@code Request} reference held past the response) are silently + * ignored. + * + * @throws NullPointerException if {@code runnable} is null + */ + public void afterResponse(Runnable runnable) { + Objects.requireNonNull(runnable, "runnable must not be null"); + afterHooks.add(runnable); + } + + /** Package-private accessor for the after-hook queue; used by RequestPreparationFilter. */ + List afterHooks() { + return afterHooks; + } +``` + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `mvn -q test -Dtest=RequestTest` +Expected: BUILD SUCCESS, 3 tests pass. + +- [ ] **Step 5: Run the full unit suite to confirm no regressions** + +Run: `mvn -q test` +Expected: BUILD SUCCESS, all tests pass (378+). + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/com/retailsvc/http/Request.java src/test/java/com/retailsvc/http/RequestTest.java +SKIP=commitlint git commit -m "feat: Add per-request after-hook queue to Request" +``` + +--- + +## Task 3: Add `Builder.afterResponseHook(...)` wiring (no execution yet) + +**Files:** +- Modify: `src/main/java/com/retailsvc/http/OpenApiServer.java` + +This task only adds plumbing. Hooks are stored on the builder and threaded into `HandlerConfig`. They aren't fired yet — that comes in Task 5. + +- [ ] **Step 1: Extend `HandlerConfig`** + +In `OpenApiServer.java`, update the record: + +```java + record HandlerConfig( + Map handlers, + List interceptors, + List decorators, + ExceptionHandler exceptionHandler, + Map extras, + Map securityValidators, + boolean externalAuth, + List afterHooks) {} +``` + +- [ ] **Step 2: Add the builder field and method** + +In `Builder`, beside the existing `interceptors` field: + +```java + private final List afterHooks = new ArrayList<>(); +``` + +And a method (place beside `interceptor(...)`): + +```java + /** + * Registers an {@link AfterResponseHook} invoked after each response is sent. Hooks run on + * the request thread inside the library's request scope, in registration order, with all + * exceptions swallowed. Hooks fire only when a {@link Request} was successfully built — + * pre-request failures (404, 405, 400 validation) do not fire hooks. + */ + public Builder afterResponseHook(AfterResponseHook hook) { + afterHooks.add(requireNonNull(hook, "hook must not be null")); + return this; + } +``` + +- [ ] **Step 3: Pass through in `build()`** + +Update the `HandlerConfig` construction in `build()` to include the new list: + +```java + HandlerConfig handlerConfig = + new HandlerConfig( + handlers, + interceptors, + decorators, + exceptionHandler, + extras, + Map.copyOf(securityValidators), + externalAuth, + List.copyOf(afterHooks)); +``` + +- [ ] **Step 4: Compile** + +Run: `mvn -q test-compile` +Expected: BUILD SUCCESS. + +- [ ] **Step 5: Run all unit tests as smoke check** + +Run: `mvn -q test` +Expected: BUILD SUCCESS, all existing tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/com/retailsvc/http/OpenApiServer.java +SKIP=commitlint git commit -m "feat: Wire AfterResponseHook through HandlerConfig" +``` + +--- + +## Task 4: Fold exception handling into `RequestPreparationFilter` + +**Files:** +- Modify: `src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java` +- Modify: `src/main/java/com/retailsvc/http/OpenApiServer.java` + +After this task, the OpenAPI route's exception handling lives in `RequestPreparationFilter`. `ExceptionFilter` remains only on `extraRoute` contexts. Behavior should be byte-for-byte identical to before (no after-hooks yet); existing tests must keep passing. + +- [ ] **Step 1: Add `ExceptionHandler` and `List` to RPF** + +Edit `src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java`. Add imports: + +```java +import com.retailsvc.http.AfterResponseHook; +import com.retailsvc.http.ExceptionHandler; +import com.retailsvc.http.Response; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +``` + +Add fields and update the constructor: + +```java + private static final Logger LOG = LoggerFactory.getLogger(RequestPreparationFilter.class); + + private final Spec spec; + private final Router router; + private final Validator validator; + private final Map bodyMappers; + private final ExceptionHandler exceptionHandler; + private final List afterHooks; + + public RequestPreparationFilter( + Spec spec, + Router router, + Validator validator, + Map bodyMappers, + ExceptionHandler exceptionHandler, + List afterHooks) { + this.spec = spec; + this.router = router; + this.validator = validator; + this.bodyMappers = Map.copyOf(bodyMappers); + this.exceptionHandler = exceptionHandler; + this.afterHooks = List.copyOf(afterHooks); + } +``` + +- [ ] **Step 2: Restructure `doFilter`** + +Replace the existing `doFilter` body. The new control flow: + +1. Routing + parameter/body validation happens outside any try/catch (it can throw — those are pre-Request failures). +2. After the `Request` is built, bind the scoped value and run the inner chain inside a `try/catch`. The chain's exceptions are routed to the user's `ExceptionHandler` so the error response is rendered before we leave the bound scope. +3. After the inner chain returns (either normally or post-recovery), fire after-hooks. Still inside the bound scope. +4. Pre-Request throws are caught at the outer level and routed to the `ExceptionHandler`. No hooks fire. + +```java + @Override + public void doFilter(HttpExchange exchange, Chain chain) throws IOException { + Request request; + try { + request = buildRequest(exchange); + } catch (RuntimeException | IOException t) { + exceptionHandler.handle(exchange, t); + return; + } + + try { + ScopedValue.where(DispatchHandler.CURRENT, request) + .call( + () -> { + runInnerChain(exchange, chain); + fireAfterHooks(exchange, request); + return null; + }); + } catch (IOException | RuntimeException e) { + throw e; + } catch (Exception e) { + throw new IOException(e); + } + } + + private Request buildRequest(HttpExchange exchange) throws IOException { + byte[] body = exchange.getRequestBody().readAllBytes(); + + HttpMethod method = HttpMethod.parse(exchange.getRequestMethod()); + String path = stripBasePath(exchange.getRequestURI().getPath()); + + var matchOpt = router.match(method, path); + if (matchOpt.isEmpty()) { + var allowed = router.allowedMethods(path); + if (allowed.isEmpty()) { + throw new NotFoundException(method + " " + path); + } + throw new MethodNotAllowedException(allowed); + } + Router.Match match = matchOpt.get(); + + Operation op = match.operation(); + validateParameters(exchange, op, match.pathParameters()); + ParsedBody parsedBody = validateAndParseBody(exchange, op, body); + + var headers = exchange.getRequestHeaders(); + return new Request( + body, + parsedBody.value(), + parsedBody.mapper(), + op.operationId(), + match.pathParameters(), + exchange.getRequestURI().getRawQuery(), + headers::getFirst); + } + + private void runInnerChain(HttpExchange exchange, Chain chain) throws IOException { + try { + chain.doFilter(exchange); + } catch (RuntimeException | IOException t) { + exceptionHandler.handle(exchange, t); + } + } +``` + +`fireAfterHooks` is added in Task 5; for now use an empty stub so the file compiles: + +```java + private void fireAfterHooks(HttpExchange exchange, Request request) { + // implemented in Task 5 + } +``` + +- [ ] **Step 3: Remove `ExceptionFilter` from the OpenAPI chain in `OpenApiServer`** + +Edit `OpenApiServer.java` constructor. Replace the three lines that add filters to the OpenAPI context: + +```java + ctx.getFilters().add(new ExceptionFilter(exceptionHandler)); + ctx.getFilters().add(new RequestPreparationFilter(spec, router, validator, bodyMappers)); + ctx.getFilters() +``` + +with: + +```java + ctx.getFilters() + .add( + new RequestPreparationFilter( + spec, + router, + validator, + bodyMappers, + exceptionHandler, + handlerConfig.afterHooks())); + ctx.getFilters() +``` + +(The `SecurityFilter` line that follows stays unchanged.) + +Leave the `ExceptionFilter` install for extras routes intact (further down in the constructor): + +```java + for (Map.Entry e : handlerConfig.extras().entrySet()) { + HttpContext extraCtx = httpServer.createContext(e.getKey()); + extraCtx.getFilters().add(new ExceptionFilter(exceptionHandler)); + extraCtx.setHandler(e.getValue()); + } +``` + +- [ ] **Step 4: Compile** + +Run: `mvn -q test-compile` +Expected: BUILD SUCCESS. + +- [ ] **Step 5: Run the full unit + integration test suite** + +Run: `mvn -q verify` +Expected: BUILD SUCCESS. All existing tests pass — moving exception handling into RPF should be observationally identical. + +If anything fails, the most likely culprits are: +- A test that introspects the filter chain by class (search `instanceof ExceptionFilter` or `ExceptionFilter.class` in test sources). +- A test relying on ExceptionFilter running on extras routes — should still work, behavior preserved. + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java src/main/java/com/retailsvc/http/OpenApiServer.java +SKIP=commitlint git commit -m "refactor: Fold exception handling into RequestPreparationFilter" +``` + +--- + +## Task 5: Implement after-hook execution + +**Files:** +- Modify: `src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java` +- Modify: `src/main/java/com/retailsvc/http/internal/DispatchHandler.java` + +DispatchHandler stashes the rendered `Response` as an exchange attribute so the runner can pass it to global hooks. On the error path, the runner builds a synthetic `Response` from the exchange. + +- [ ] **Step 1: Have DispatchHandler stash the rendered Response** + +Edit `src/main/java/com/retailsvc/http/internal/DispatchHandler.java`. Replace the `handle` method: + +```java + public static final ScopedValue CURRENT = ScopedValue.newInstance(); + public static final String RESPONSE_ATTR = "com.retailsvc.http.response"; + + ... + + @Override + public void handle(HttpExchange exchange) throws IOException { + Request request = CURRENT.get(); + RequestHandler handler = handlers.get(request.operationId()); + if (handler == null) { + throw new MissingOperationHandlerException(request.operationId()); + } + Response response = invoke(0, request, handler); + for (ResponseDecorator decorator : decorators) { + response = decorator.decorate(request, response); + } + exchange.setAttribute(RESPONSE_ATTR, response); + renderer.render(exchange, response); + } +``` + +- [ ] **Step 2: Implement `fireAfterHooks` in RequestPreparationFilter** + +Replace the stub `fireAfterHooks` introduced in Task 4. Imports already needed should be in place; add `com.sun.net.httpserver.Headers` and `java.util.LinkedHashMap` if not already imported. + +```java + private void fireAfterHooks(HttpExchange exchange, Request request) { + Response response = resolveResponse(exchange); + List snapshot = List.copyOf(request.afterHooks()); + + for (AfterResponseHook hook : afterHooks) { + try { + hook.after(request, response); + } catch (Throwable t) { + LOG.debug("after-response hook threw", t); + } + } + for (Runnable runnable : snapshot) { + try { + runnable.run(); + } catch (Throwable t) { + LOG.debug("after-response runnable threw", t); + } + } + } + + private static Response resolveResponse(HttpExchange exchange) { + Object stashed = exchange.getAttribute(DispatchHandler.RESPONSE_ATTR); + if (stashed instanceof Response r) { + return r; + } + Headers headers = exchange.getResponseHeaders(); + String contentType = headers.getFirst("Content-Type"); + Map flat = new LinkedHashMap<>(); + for (Map.Entry> e : headers.entrySet()) { + List values = e.getValue(); + if (values != null && !values.isEmpty()) { + flat.put(e.getKey(), values.get(0)); + } + } + return new Response(exchange.getResponseCode(), null, contentType, flat); + } +``` + +Note: `List.copyOf(request.afterHooks())` snapshots the queue so runnables added during hook execution don't recurse. + +- [ ] **Step 3: Compile** + +Run: `mvn -q test-compile` +Expected: BUILD SUCCESS. + +- [ ] **Step 4: Run the full suite — still no new tests yet** + +Run: `mvn -q verify` +Expected: BUILD SUCCESS. All existing tests still pass; the new code is just dormant code paths for tests that don't register any hooks. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java src/main/java/com/retailsvc/http/internal/DispatchHandler.java +SKIP=commitlint git commit -m "feat: Fire after-response hooks inside the request scope" +``` + +--- + +## Task 6: Integration tests for end-to-end behavior + +**Files:** +- Create: `src/test/java/com/retailsvc/http/AfterResponseHookIT.java` + +This is the meat of the verification. Each test builds a fresh `OpenApiServer` against the existing `src/test/resources/openapi.json` (or whichever fixture the rest of the IT suite uses — verify before writing), registers handlers and hooks, makes a real HTTP request, and asserts hook side effects. + +- [ ] **Step 1: Find the canonical IT harness** + +Run: `ls src/test/java/com/retailsvc/http/*IT.java` and pick one (e.g. `OpenApiServerIT.java`) to mirror the boot pattern. + +Run: `mvn -q test-compile 2>&1 | tail -5` to make sure compile is clean before writing tests. + +- [ ] **Step 2: Write the integration test class** + +Copy the boot scaffolding from the chosen existing IT. The skeleton (adapt the spec-load mechanic to whatever the existing IT does — likely a JSON load via Gson or Jackson): + +```java +package com.retailsvc.http; + +import static java.net.http.HttpClient.newHttpClient; +import static java.net.http.HttpRequest.BodyPublishers.noBody; +import static org.assertj.core.api.Assertions.assertThat; + +import com.retailsvc.http.internal.DispatchHandler; +import com.retailsvc.http.spec.Spec; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class AfterResponseHookIT { + + // Reuse whatever helper the other ITs use to load the spec and pick a handler operation. + // Replace these constants with the operationId / path / method actually present in the fixture. + private static final String OK_OPERATION_ID = "getThings"; // <-- adapt + private static final String OK_PATH = "/things"; // <-- adapt + private static final String NOT_FOUND_PATH = "/does-not-exist"; + + private static Spec loadSpec() { + // Mirror the existing IT's loader. + return TestSpecs.openapi(); // <-- replace with the actual call + } + + private static URI uri(OpenApiServer server, String path) { + return URI.create("http://localhost:" + server.listenPort() + path); + } + + @Test + void globalHookFiresAfterSuccessfulResponse() throws Exception { + AtomicReference capturedRequest = new AtomicReference<>(); + AtomicReference capturedResponse = new AtomicReference<>(); + + try (OpenApiServer server = + OpenApiServer.builder() + .spec(loadSpec()) + .port(0) + .handlers(Map.of(OK_OPERATION_ID, req -> Response.status(204))) + .afterResponseHook( + (req, resp) -> { + capturedRequest.set(req); + capturedResponse.set(resp); + }) + .build()) { + + HttpResponse resp = + newHttpClient() + .send( + HttpRequest.newBuilder(uri(server, OK_PATH)).GET().build(), + BodyHandlers.discarding()); + + assertThat(resp.statusCode()).isEqualTo(204); + assertThat(capturedRequest.get()).isNotNull(); + assertThat(capturedRequest.get().operationId()).isEqualTo(OK_OPERATION_ID); + assertThat(capturedResponse.get()).isNotNull(); + assertThat(capturedResponse.get().status()).isEqualTo(204); + } + } + + @Test + void perRequestRunnablesFireInOrder() throws Exception { + List log = new CopyOnWriteArrayList<>(); + + try (OpenApiServer server = + OpenApiServer.builder() + .spec(loadSpec()) + .port(0) + .handlers( + Map.of( + OK_OPERATION_ID, + req -> { + req.afterResponse(() -> log.add("first")); + req.afterResponse(() -> log.add("second")); + return Response.status(204); + })) + .build()) { + + newHttpClient() + .send( + HttpRequest.newBuilder(uri(server, OK_PATH)).GET().build(), + BodyHandlers.discarding()); + + // Hooks run on the request thread before the JDK server moves on. The HTTP client + // already saw the response bytes; the hook ran synchronously after the bytes were + // flushed. + assertThat(log).containsExactly("first", "second"); + } + } + + @Test + void hookExceptionDoesNotAffectClientOrOtherHooks() throws Exception { + List log = new CopyOnWriteArrayList<>(); + + try (OpenApiServer server = + OpenApiServer.builder() + .spec(loadSpec()) + .port(0) + .handlers(Map.of(OK_OPERATION_ID, req -> Response.status(204))) + .afterResponseHook((req, resp) -> { throw new RuntimeException("boom"); }) + .afterResponseHook((req, resp) -> log.add("second-ran")) + .build()) { + + HttpResponse resp = + newHttpClient() + .send( + HttpRequest.newBuilder(uri(server, OK_PATH)).GET().build(), + BodyHandlers.discarding()); + + assertThat(resp.statusCode()).isEqualTo(204); + assertThat(log).containsExactly("second-ran"); + } + } + + @Test + void hookFiresOnHandlerException() throws Exception { + AtomicReference capturedResponse = new AtomicReference<>(); + + try (OpenApiServer server = + OpenApiServer.builder() + .spec(loadSpec()) + .port(0) + .handlers( + Map.of( + OK_OPERATION_ID, + req -> { throw new RuntimeException("kapow"); })) + .afterResponseHook((req, resp) -> capturedResponse.set(resp)) + .build()) { + + HttpResponse resp = + newHttpClient() + .send( + HttpRequest.newBuilder(uri(server, OK_PATH)).GET().build(), + BodyHandlers.discarding()); + + assertThat(resp.statusCode()).isEqualTo(500); + assertThat(capturedResponse.get()).isNotNull(); + assertThat(capturedResponse.get().status()).isEqualTo(500); + assertThat(capturedResponse.get().body()).isNull(); + } + } + + @Test + void preRequestFailureSkipsHooks() throws Exception { + List log = new CopyOnWriteArrayList<>(); + + try (OpenApiServer server = + OpenApiServer.builder() + .spec(loadSpec()) + .port(0) + .handlers(Map.of(OK_OPERATION_ID, req -> Response.status(204))) + .afterResponseHook((req, resp) -> log.add("fired")) + .build()) { + + HttpResponse resp = + newHttpClient() + .send( + HttpRequest.newBuilder(uri(server, NOT_FOUND_PATH)).GET().build(), + BodyHandlers.discarding()); + + assertThat(resp.statusCode()).isEqualTo(404); + assertThat(log).isEmpty(); + } + } + + @Test + void hookSeesScopedRequestAndSameThreadAsHandler() throws Exception { + AtomicReference handlerThread = new AtomicReference<>(); + AtomicReference hookThread = new AtomicReference<>(); + AtomicReference hookScopedRequest = new AtomicReference<>(); + + try (OpenApiServer server = + OpenApiServer.builder() + .spec(loadSpec()) + .port(0) + .handlers( + Map.of( + OK_OPERATION_ID, + req -> { + handlerThread.set(Thread.currentThread()); + return Response.status(204); + })) + .afterResponseHook( + (req, resp) -> { + hookThread.set(Thread.currentThread()); + hookScopedRequest.set(DispatchHandler.CURRENT.get()); + }) + .build()) { + + newHttpClient() + .send( + HttpRequest.newBuilder(uri(server, OK_PATH)).GET().build(), + BodyHandlers.discarding()); + + assertThat(hookThread.get()).isSameAs(handlerThread.get()); + assertThat(hookScopedRequest.get()).isNotNull(); + assertThat(hookScopedRequest.get().operationId()).isEqualTo(OK_OPERATION_ID); + } + } +} +``` + +**Adapter notes the implementer must resolve before running:** +- The spec-loading helper (`TestSpecs.openapi()` placeholder) needs to be replaced with the loader the other ITs use. Open one IT and copy the call. +- `OK_OPERATION_ID` / `OK_PATH` must match an operation in `src/test/resources/openapi.json` that accepts `GET` and has no required body. Pick one or add a no-op operation if none exists. `NOT_FOUND_PATH` must NOT be served by the spec. + +- [ ] **Step 3: Run the integration tests and verify they fail to compile (placeholders)** + +Run: `mvn -q test-compile -DskipTests=false 2>&1 | tail -20` +Expected: compile errors point at the placeholder constants/helpers. Resolve them. + +- [ ] **Step 4: Fix the placeholders, then run the integration tests** + +Run: `mvn -q verify -Dit.test=AfterResponseHookIT` +Expected: BUILD SUCCESS, all 6 tests pass. + +If any test fails, prioritise fixing rather than retreating to mocks. Likely culprits: +- `perRequestRunnablesFireInOrder` race: the runnable runs synchronously on the request thread, but the test client returned as soon as bytes were flushed. The JDK `HttpServer` writes via `OutputStream.close()` inside `ResponseRenderer.render`, so by the time `chain.doFilter` returns we are guaranteed past send. The hook runs before `runInnerChain` returns. Still synchronous on the request thread, so the asserted list is populated by the time the client read returns. If you see flakiness, add a short busy-wait — but it should not be necessary. + +- [ ] **Step 5: Run the entire suite to make sure nothing else regressed** + +Run: `mvn -q verify` +Expected: BUILD SUCCESS, all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/test/java/com/retailsvc/http/AfterResponseHookIT.java +SKIP=commitlint git commit -m "test: Add integration tests for after-response hooks" +``` + +--- + +## Task 7: Builder NPE test + +**Files:** +- Modify or create: a small unit test next to existing builder-validation tests. + +- [ ] **Step 1: Locate the builder test class** + +Run: `ls src/test/java/com/retailsvc/http/ | grep -i builder` and pick the file (likely `OpenApiServerBuilderTest.java` or similar). If none exists, add the test method into a class named consistently with siblings. + +- [ ] **Step 2: Add the NPE test** + +```java + @Test + void afterResponseHookRejectsNull() { + assertThrows( + NullPointerException.class, + () -> OpenApiServer.builder().afterResponseHook(null)); + } +``` + +- [ ] **Step 3: Run the test** + +Run: `mvn -q test -Dtest=#afterResponseHookRejectsNull` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add +SKIP=commitlint git commit -m "test: Builder.afterResponseHook null-guard" +``` + +--- + +## Task 8: README + javadoc polish + +**Files:** +- Modify: `README.md` +- Touch as needed: javadoc on `OpenApiServer.Builder.afterResponseHook`, `Request.afterResponse`, and `AfterResponseHook` — already drafted in earlier tasks; just verify consistency. + +- [ ] **Step 1: Find the section to extend** + +Run: `grep -n "interceptor\|ResponseDecorator\|exceptionHandler" README.md | head -20` — pick the most natural spot (typically the "Hooks" / "Filters" / "Extensibility" section). + +- [ ] **Step 2: Add a short subsection** + +Example wording (adapt to the README's existing voice): + +````markdown +### After-response hooks + +Register code to run after the response has been sent. Hooks run on the request virtual thread, +inside the library's request scope, with exceptions swallowed. + +```java +OpenApiServer.builder() + .afterResponseHook((req, resp) -> + metrics.timer("http.request").record(req.operationId(), resp.status())) + .handlers(...) + .build(); +``` + +Handlers can also queue per-request runnables: + +```java +Map handlers = Map.of( + "getThings", req -> { + req.afterResponse(() -> auditLog.flush()); + return Response.ok(things); + }); +``` + +Global hooks run first (registration order), then per-request runnables (FIFO). Pre-request +failures (404, 405, validation) do not fire hooks. +```` + +- [ ] **Step 3: Commit** + +```bash +git add README.md +SKIP=commitlint git commit -m "docs: Document after-response hooks" +``` + +--- + +## Task 9: SonarLint sweep + final verify + +Per project memory, run SonarLint MCP on every file touched in this branch before pushing, and fix any new findings. + +- [ ] **Step 1: Touched files inventory** + +``` +src/main/java/com/retailsvc/http/AfterResponseHook.java +src/main/java/com/retailsvc/http/Request.java +src/main/java/com/retailsvc/http/OpenApiServer.java +src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java +src/main/java/com/retailsvc/http/internal/DispatchHandler.java +src/test/java/com/retailsvc/http/RequestTest.java +src/test/java/com/retailsvc/http/AfterResponseHookIT.java +src/test/java/com/retailsvc/http/.java +README.md +``` + +Run SonarLint MCP `analyzeFile` for each `.java` file in the list. Fix every NEW issue in the same branch. (Note: per project memory, SonarLint MCP cannot read files only present in the worktree — if it returns `not_found`, rely on CI scan for that file.) + +- [ ] **Step 2: Final verify** + +Run: `mvn -q verify` +Expected: BUILD SUCCESS, all tests pass, JaCoCo report generated. + +- [ ] **Step 3: Confirm POM is sorted** + +Run: `mvn -q sortpom:verify` +Expected: BUILD SUCCESS (no diff). + +- [ ] **Step 4: Push the branch** + +```bash +git push -u origin feat/after-hook +``` + +(Per project memory, do NOT attempt to open a PR with `gh` — let the user do it manually.) + +- [ ] **Step 5: Report** + +Summarise: tasks complete, test counts, branch pushed. Hand off to the user to open the PR. From 1e03db18e82d245dcf299fd0fb5430322fc87a31 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 13:02:50 +0200 Subject: [PATCH 03/14] feat: Add AfterResponseHook interface --- .../com/retailsvc/http/AfterResponseHook.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/main/java/com/retailsvc/http/AfterResponseHook.java diff --git a/src/main/java/com/retailsvc/http/AfterResponseHook.java b/src/main/java/com/retailsvc/http/AfterResponseHook.java new file mode 100644 index 0000000..e08b35b --- /dev/null +++ b/src/main/java/com/retailsvc/http/AfterResponseHook.java @@ -0,0 +1,22 @@ +package com.retailsvc.http; + +/** + * Callback invoked after an HTTP response has been written to the client. Runs on the same virtual + * thread that handled the request, inside the library's request {@link ScopedValue} binding. + * + *

Hooks fire only when a {@link Request} was successfully constructed — i.e., routing and + * parameter/body validation passed. Pre-request failures (404, 405, 400 validation) do not fire + * hooks. + * + *

On the error path, {@link Response#body()} is always {@code null} because the body bytes have + * already been sent. {@link Response#status()} and {@link Response#headers()} reflect what was + * written to the wire. + * + *

Exceptions thrown by a hook are logged at DEBUG and swallowed; subsequent hooks still run. + * Hooks compose in registration order. + */ +@FunctionalInterface +public interface AfterResponseHook { + + void after(Request request, Response response); +} From 1f543214c2b150af41bd4726613c51cc8316407b Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 13:07:53 +0200 Subject: [PATCH 04/14] feat: Add per-request after-hook queue to Request --- src/main/java/com/retailsvc/http/Request.java | 54 ++++++++++++++++++- .../java/com/retailsvc/http/RequestTest.java | 44 +++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/retailsvc/http/Request.java b/src/main/java/com/retailsvc/http/Request.java index 20b3c78..dce1b96 100644 --- a/src/main/java/com/retailsvc/http/Request.java +++ b/src/main/java/com/retailsvc/http/Request.java @@ -3,7 +3,9 @@ import com.retailsvc.http.spec.HttpMethod; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -33,6 +35,7 @@ public final class Request { private final UnaryOperator headerLookup; private final Map principals; private Map queryParamCache; + private final List afterHooks; /** * Builds a {@code Request} from transport-neutral primitives. Adapters call this; handlers @@ -145,6 +148,34 @@ public Request( this.method = method; this.headerLookup = headerLookup; this.principals = Map.copyOf(principals); + this.afterHooks = new ArrayList<>(); + } + + // Package-private: lets withPrincipals(...) thread the after-hook queue through so that + // runnables registered on either the original Request or the principals-enriched copy + // land in the same backing list. + @SuppressWarnings("java:S107") + Request( + byte[] body, + Object parsed, + TypeMapper bodyMapper, + String operationId, + Map pathParameters, + String rawQuery, + UnaryOperator headerLookup, + Map principals, + HttpMethod method, + List afterHooks) { + this.body = body; + this.parsed = parsed; + this.bodyMapper = bodyMapper; + this.operationId = operationId; + this.pathParameters = pathParameters; + this.rawQuery = rawQuery; + this.method = method; + this.headerLookup = headerLookup; + this.principals = Map.copyOf(principals); + this.afterHooks = afterHooks; } public byte[] bytes() { @@ -286,7 +317,28 @@ public Request withPrincipals(Map principals) { rawQuery, headerLookup, principals, - method); + method, + afterHooks); + } + + /** + * Queues a {@link Runnable} to execute after the HTTP response has been sent to the client. Runs + * on the request thread inside the library's request {@link ScopedValue} binding. Multiple calls + * queue FIFO. Exceptions thrown by the runnable are logged at DEBUG and swallowed. + * + *

Calls made after the runner has snapshotted the queue (e.g. from inside a running hook, or + * from a leaked {@code Request} reference held past the response) are silently ignored. + * + * @throws NullPointerException if {@code runnable} is null + */ + public void afterResponse(Runnable runnable) { + Objects.requireNonNull(runnable, "runnable must not be null"); + afterHooks.add(runnable); + } + + /** Package-private accessor for the after-hook queue; used by RequestPreparationFilter. */ + List afterHooks() { + return afterHooks; } private static Map parseQuery(String query) { diff --git a/src/test/java/com/retailsvc/http/RequestTest.java b/src/test/java/com/retailsvc/http/RequestTest.java index 7b796b4..b2192d1 100644 --- a/src/test/java/com/retailsvc/http/RequestTest.java +++ b/src/test/java/com/retailsvc/http/RequestTest.java @@ -2,12 +2,15 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertThrows; import com.fasterxml.jackson.databind.ObjectMapper; import com.retailsvc.http.internal.DispatchHandler; import com.retailsvc.http.spec.HttpMethod; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import java.util.function.UnaryOperator; @@ -232,6 +235,47 @@ void withPrincipalsDoesNotShareUnderlyingMap() { assertThat(copy.principal("a")).contains("b"); } + private static Request newRequest() { + return new Request(new byte[0], null, null, "op", Map.of(), null, name -> null); + } + + @Test + void afterResponseRejectsNull() { + Request request = newRequest(); + assertThrows(NullPointerException.class, () -> request.afterResponse(null)); + } + + @Test + void afterResponseQueuesInOrder() { + Request request = newRequest(); + List log = new ArrayList<>(); + request.afterResponse(() -> log.add("first")); + request.afterResponse(() -> log.add("second")); + + for (Runnable r : request.afterHooks()) { + r.run(); + } + + assertThat(log).containsExactly("first", "second"); + } + + @Test + void withPrincipalsSharesAfterHookQueue() { + Request original = newRequest(); + List log = new ArrayList<>(); + original.afterResponse(() -> log.add("from-original")); + + Request enriched = original.withPrincipals(Map.of("scheme", "principal")); + enriched.afterResponse(() -> log.add("from-enriched")); + + for (Runnable r : original.afterHooks()) { + r.run(); + } + + assertThat(log).containsExactly("from-original", "from-enriched"); + assertThat(original.afterHooks()).isSameAs(enriched.afterHooks()); + } + @Test void exposesMethod() { Request req = From dd4a6f9050be614693429536790e68c5ee430912 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 13:13:47 +0200 Subject: [PATCH 05/14] feat: Wire AfterResponseHook through HandlerConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add afterHooks field to HandlerConfig record, add afterHooks list and afterResponseHook(...) builder method beside interceptor(...), and pass List.copyOf(afterHooks) into HandlerConfig in build(). Hooks are stored but not yet fired — execution wiring comes in a later task. --- .../java/com/retailsvc/http/OpenApiServer.java | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index 16508b3..2a0de9e 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -60,7 +60,8 @@ record HandlerConfig( ExceptionHandler exceptionHandler, Map extras, Map securityValidators, - boolean externalAuth) {} + boolean externalAuth, + List afterHooks) {} OpenApiServer( Spec spec, @@ -178,6 +179,7 @@ public static final class Builder { private Map handlers; private final List decorators = new ArrayList<>(); private final List interceptors = new ArrayList<>(); + private final List afterHooks = new ArrayList<>(); private ExceptionHandler exceptionHandler; private int port = DEFAULT_PORT; private InetAddress bindAddress; @@ -228,6 +230,17 @@ public Builder interceptor(RequestInterceptor interceptor) { return this; } + /** + * Registers an {@link AfterResponseHook} invoked after each response is sent. Hooks run on the + * request thread inside the library's request scope, in registration order, with all exceptions + * swallowed. Hooks fire only when a {@link Request} was successfully built — pre-request + * failures (404, 405, 400 validation) do not fire hooks. + */ + public Builder afterResponseHook(AfterResponseHook hook) { + afterHooks.add(requireNonNull(hook, "hook must not be null")); + return this; + } + /** * Registers a {@link SchemeValidator} for the OpenAPI security scheme named {@code schemeName}. * The library extracts a {@link Credential} per request and hands it to this callback; return a @@ -332,7 +345,8 @@ public OpenApiServer build() throws IOException { effectiveExceptionHandler, extras, Map.copyOf(securityValidators), - externalAuth); + externalAuth, + List.copyOf(afterHooks)); return new OpenApiServer( spec, resolved, handlerConfig, port, bindAddress, shutdownTimeoutSeconds); } From 38f323a6328c7cadf7966698885f63f422103102 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 13:20:04 +0200 Subject: [PATCH 06/14] refactor: Fold exception handling into RequestPreparationFilter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move inner-chain exception catching from ExceptionFilter into RequestPreparationFilter so after-hooks can fire inside the still-bound ScopedValue block (Task 4 of 9 — behavior-preserving). ExceptionFilter is kept and still wires onto extra-route contexts. --- .../com/retailsvc/http/OpenApiServer.java | 12 ++- .../internal/RequestPreparationFilter.java | 88 ++++++++++++++----- .../RequestPreparationFilterTest.java | 17 +++- 3 files changed, 92 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index 2a0de9e..12371ef 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -95,8 +95,16 @@ record HandlerConfig( String basePath = Optional.ofNullable(spec.basePath()).orElse("/"); HttpContext ctx = httpServer.createContext(basePath); - ctx.getFilters().add(new ExceptionFilter(exceptionHandler, renderer)); - ctx.getFilters().add(new RequestPreparationFilter(spec, router, validator, bodyMappers)); + ctx.getFilters() + .add( + new RequestPreparationFilter( + spec, + router, + validator, + bodyMappers, + exceptionHandler, + renderer, + handlerConfig.afterHooks())); ctx.getFilters() .add( new SecurityFilter( diff --git a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java index c6942e0..091e22c 100644 --- a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java +++ b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java @@ -1,8 +1,11 @@ package com.retailsvc.http.internal; +import com.retailsvc.http.AfterResponseHook; +import com.retailsvc.http.ExceptionHandler; import com.retailsvc.http.MethodNotAllowedException; import com.retailsvc.http.NotFoundException; import com.retailsvc.http.Request; +import com.retailsvc.http.Response; import com.retailsvc.http.TypeMapper; import com.retailsvc.http.ValidationException; import com.retailsvc.http.spec.HttpMethod; @@ -17,25 +20,42 @@ import com.sun.net.httpserver.HttpExchange; import java.io.IOException; import java.util.HashMap; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public final class RequestPreparationFilter extends Filter { + private static final Logger LOG = LoggerFactory.getLogger(RequestPreparationFilter.class); private static final String BODY_POINTER = "/body"; private final Spec spec; private final Router router; private final Validator validator; private final Map bodyMappers; + private final ExceptionHandler exceptionHandler; + private final ResponseRenderer renderer; + private final List afterHooks; + @SuppressWarnings("java:S107") public RequestPreparationFilter( - Spec spec, Router router, Validator validator, Map bodyMappers) { + Spec spec, + Router router, + Validator validator, + Map bodyMappers, + ExceptionHandler exceptionHandler, + ResponseRenderer renderer, + List afterHooks) { this.spec = spec; this.router = router; this.validator = validator; this.bodyMappers = Map.copyOf(bodyMappers); + this.exceptionHandler = exceptionHandler; + this.renderer = renderer; + this.afterHooks = List.copyOf(afterHooks); } @Override @@ -45,6 +65,31 @@ public String description() { @Override public void doFilter(HttpExchange exchange, Chain chain) throws IOException { + Request request; + try { + request = buildRequest(exchange); + } catch (RuntimeException | IOException t) { + Response response = exceptionHandler.handle(t); + renderer.render(exchange, response); + return; + } + + try { + ScopedValue.where(DispatchHandler.CURRENT, request) + .call( + () -> { + runInnerChain(exchange, chain); + fireAfterHooks(exchange, request); + return null; + }); + } catch (IOException | RuntimeException e) { + throw e; + } catch (Exception e) { + throw new IOException(e); + } + } + + private Request buildRequest(HttpExchange exchange) throws IOException { byte[] body = exchange.getRequestBody().readAllBytes(); HttpMethod method = HttpMethod.parse(exchange.getRequestMethod()); @@ -65,32 +110,31 @@ public void doFilter(HttpExchange exchange, Chain chain) throws IOException { ParsedBody parsedBody = validateAndParseBody(exchange, op, body); var headers = exchange.getRequestHeaders(); - Request request = - new Request( - body, - parsedBody.value(), - parsedBody.mapper(), - op.operationId(), - match.pathParameters(), - exchange.getRequestURI().getRawQuery(), - headers::getFirst, - Map.of(), - method); + return new Request( + body, + parsedBody.value(), + parsedBody.mapper(), + op.operationId(), + match.pathParameters(), + exchange.getRequestURI().getRawQuery(), + headers::getFirst, + Map.of(), + method); + } + private void runInnerChain(HttpExchange exchange, Chain chain) throws IOException { try { - ScopedValue.where(DispatchHandler.CURRENT, request) - .call( - () -> { - chain.doFilter(exchange); - return null; - }); - } catch (IOException | RuntimeException e) { - throw e; - } catch (Exception e) { - throw new IOException(e); + chain.doFilter(exchange); + } catch (RuntimeException | IOException t) { + Response response = exceptionHandler.handle(t); + renderer.render(exchange, response); } } + private void fireAfterHooks(HttpExchange exchange, Request request) { + // implemented in Task 5 + } + private String stripBasePath(String path) { String base = spec.basePath(); if (base == null || base.isEmpty() || base.equals("/")) { diff --git a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java index 3871a46..2c4b359 100644 --- a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java +++ b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; +import com.retailsvc.http.ExceptionHandler; import com.retailsvc.http.MethodNotAllowedException; import com.retailsvc.http.NotFoundException; import com.retailsvc.http.Request; @@ -26,6 +27,7 @@ import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpExchange; import java.io.ByteArrayInputStream; +import java.io.IOException; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.List; @@ -77,8 +79,21 @@ public byte[] writeTo(Object value) { } }; Map mappers = Map.of("application/json", textMapper); + ExceptionHandler rethrow = + t -> { + if (t instanceof RuntimeException re) { + throw re; + } + throw new IllegalStateException(t); + }; return new RequestPreparationFilter( - spec, new Router(spec.operations()), new DefaultValidator(spec::resolveSchema), mappers); + spec, + new Router(spec.operations()), + new DefaultValidator(spec::resolveSchema), + mappers, + rethrow, + new ResponseRenderer(mappers), + List.of()); } @Test From d9e1007d32fbb88b2fe246bfc4fe3d5f6270c40a Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 13:30:37 +0200 Subject: [PATCH 07/14] feat: Fire after-response hooks inside the request scope DispatchHandler stashes the rendered Response as an exchange attribute so fireAfterHooks can pass the real response to global and per-request hooks. resolveResponse falls back to synthesising a Response from exchange headers/status on the error path. A try/finally around runInnerChain ensures hooks fire even when exceptionHandler.handle itself throws. --- src/main/java/com/retailsvc/http/Request.java | 4 +- .../http/internal/DispatchHandler.java | 2 + .../internal/RequestPreparationFilter.java | 46 +++++++++++++++++-- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/retailsvc/http/Request.java b/src/main/java/com/retailsvc/http/Request.java index dce1b96..a66d56f 100644 --- a/src/main/java/com/retailsvc/http/Request.java +++ b/src/main/java/com/retailsvc/http/Request.java @@ -336,8 +336,8 @@ public void afterResponse(Runnable runnable) { afterHooks.add(runnable); } - /** Package-private accessor for the after-hook queue; used by RequestPreparationFilter. */ - List afterHooks() { + /** Internal accessor for the after-hook queue; used by RequestPreparationFilter. */ + public List afterHooks() { return afterHooks; } diff --git a/src/main/java/com/retailsvc/http/internal/DispatchHandler.java b/src/main/java/com/retailsvc/http/internal/DispatchHandler.java index d1234e9..aeb9bee 100644 --- a/src/main/java/com/retailsvc/http/internal/DispatchHandler.java +++ b/src/main/java/com/retailsvc/http/internal/DispatchHandler.java @@ -15,6 +15,7 @@ public final class DispatchHandler implements HttpHandler { public static final ScopedValue CURRENT = ScopedValue.newInstance(); + public static final String RESPONSE_ATTR = "com.retailsvc.http.response"; private final Map handlers; private final List interceptors; @@ -43,6 +44,7 @@ public void handle(HttpExchange exchange) throws IOException { for (ResponseDecorator decorator : decorators) { response = decorator.decorate(request, response); } + exchange.setAttribute(RESPONSE_ATTR, response); renderer.render(exchange, response); } diff --git a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java index 091e22c..dfa9d12 100644 --- a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java +++ b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java @@ -17,9 +17,11 @@ import com.retailsvc.http.validate.ValidationError; import com.retailsvc.http.validate.Validator; import com.sun.net.httpserver.Filter; +import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpExchange; import java.io.IOException; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -78,8 +80,11 @@ public void doFilter(HttpExchange exchange, Chain chain) throws IOException { ScopedValue.where(DispatchHandler.CURRENT, request) .call( () -> { - runInnerChain(exchange, chain); - fireAfterHooks(exchange, request); + try { + runInnerChain(exchange, chain); + } finally { + fireAfterHooks(exchange, request); + } return null; }); } catch (IOException | RuntimeException e) { @@ -132,7 +137,42 @@ private void runInnerChain(HttpExchange exchange, Chain chain) throws IOExceptio } private void fireAfterHooks(HttpExchange exchange, Request request) { - // implemented in Task 5 + Response response = resolveResponse(exchange); + List snapshot = List.copyOf(request.afterHooks()); + + for (AfterResponseHook hook : afterHooks) { + try { + hook.after(request, response); + } catch (Throwable t) { + LOG.debug("after-response hook threw", t); + } + } + for (Runnable runnable : snapshot) { + try { + runnable.run(); + } catch (Throwable t) { + LOG.debug("after-response runnable threw", t); + } + } + } + + private static Response resolveResponse(HttpExchange exchange) { + Object stashed = exchange.getAttribute(DispatchHandler.RESPONSE_ATTR); + if (stashed instanceof Response r) { + return r; + } + Headers headers = exchange.getResponseHeaders(); + String contentType = headers != null ? headers.getFirst("Content-Type") : null; + Map flat = new LinkedHashMap<>(); + if (headers != null) { + for (Map.Entry> e : headers.entrySet()) { + List values = e.getValue(); + if (values != null && !values.isEmpty()) { + flat.put(e.getKey(), values.get(0)); + } + } + } + return new Response(exchange.getResponseCode(), null, contentType, flat); } private String stripBasePath(String path) { From da6fcc5d2c2cac5f3dc438316ebcc39d75398774 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 13:34:56 +0200 Subject: [PATCH 08/14] refactor: Return unmodifiable view from Request.afterHooks() --- src/main/java/com/retailsvc/http/Request.java | 3 ++- src/test/java/com/retailsvc/http/RequestTest.java | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/retailsvc/http/Request.java b/src/main/java/com/retailsvc/http/Request.java index a66d56f..d5dc0f3 100644 --- a/src/main/java/com/retailsvc/http/Request.java +++ b/src/main/java/com/retailsvc/http/Request.java @@ -4,6 +4,7 @@ import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -338,7 +339,7 @@ public void afterResponse(Runnable runnable) { /** Internal accessor for the after-hook queue; used by RequestPreparationFilter. */ public List afterHooks() { - return afterHooks; + return Collections.unmodifiableList(afterHooks); } private static Map parseQuery(String query) { diff --git a/src/test/java/com/retailsvc/http/RequestTest.java b/src/test/java/com/retailsvc/http/RequestTest.java index b2192d1..23c1dad 100644 --- a/src/test/java/com/retailsvc/http/RequestTest.java +++ b/src/test/java/com/retailsvc/http/RequestTest.java @@ -273,7 +273,12 @@ void withPrincipalsSharesAfterHookQueue() { } assertThat(log).containsExactly("from-original", "from-enriched"); - assertThat(original.afterHooks()).isSameAs(enriched.afterHooks()); + // Adding via one Request is visible via the other — the backing list is shared. + original.afterResponse(() -> log.add("from-original-again")); + List enrichedView = enriched.afterHooks(); + assertThat(enrichedView).hasSize(3); + enrichedView.get(2).run(); + assertThat(log).containsExactly("from-original", "from-enriched", "from-original-again"); } @Test From 0e844be120c780aa902ef5765af3b799d73a07bf Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 13:45:28 +0200 Subject: [PATCH 09/14] test: Add integration tests for after-response hooks --- .../retailsvc/http/AfterResponseHookIT.java | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 src/test/java/com/retailsvc/http/AfterResponseHookIT.java diff --git a/src/test/java/com/retailsvc/http/AfterResponseHookIT.java b/src/test/java/com/retailsvc/http/AfterResponseHookIT.java new file mode 100644 index 0000000..fc0175d --- /dev/null +++ b/src/test/java/com/retailsvc/http/AfterResponseHookIT.java @@ -0,0 +1,239 @@ +package com.retailsvc.http; + +import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR; +import static java.net.HttpURLConnection.HTTP_NOT_FOUND; +import static java.net.HttpURLConnection.HTTP_NO_CONTENT; +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.gson.Gson; +import com.retailsvc.http.internal.DispatchHandler; +import com.retailsvc.http.spec.Spec; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class AfterResponseHookIT { + + // get-data accepts GET /data with no required parameters + private static final String OK_OPERATION_ID = "get-data"; + private static final String OK_PATH = "/api/v1/data"; + private static final String NOT_FOUND_PATH = "/api/v1/does-not-exist"; + + private static Spec loadSpec() { + Gson gson = new Gson(); + try (InputStream in = AfterResponseHookIT.class.getResourceAsStream("/openapi.json")) { + String text = new String(in.readAllBytes(), StandardCharsets.UTF_8); + @SuppressWarnings("unchecked") + Map raw = (Map) gson.fromJson(text, Map.class); + return Spec.from(raw); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static OpenApiServer.Builder baseBuilder() { + return OpenApiServer.builder() + .spec(loadSpec()) + .port(0) + .securityValidator("apiKeyAuth", (req, cred) -> Optional.empty()) + .securityValidator("bearerAuth", (req, cred) -> Optional.empty()) + .securityValidator("basicAuth", (req, cred) -> Optional.empty()); + } + + private static URI uri(OpenApiServer server, String path) { + return URI.create("http://localhost:" + server.listenPort() + path); + } + + @Test + void globalHookFiresAfterSuccessfulResponse() throws Exception { + AtomicReference capturedRequest = new AtomicReference<>(); + AtomicReference capturedResponse = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + + try (OpenApiServer server = + baseBuilder() + .handlers(Map.of(OK_OPERATION_ID, req -> Response.status(HTTP_NO_CONTENT))) + .afterResponseHook( + (req, resp) -> { + capturedRequest.set(req); + capturedResponse.set(resp); + latch.countDown(); + }) + .build()) { + + HttpResponse resp = + HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder(uri(server, OK_PATH)).GET().build(), + BodyHandlers.discarding()); + + assertThat(resp.statusCode()).isEqualTo(HTTP_NO_CONTENT); + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(capturedRequest.get()).isNotNull(); + assertThat(capturedRequest.get().operationId()).isEqualTo(OK_OPERATION_ID); + assertThat(capturedResponse.get()).isNotNull(); + assertThat(capturedResponse.get().status()).isEqualTo(HTTP_NO_CONTENT); + } + } + + @Test + void perRequestRunnablesFireInOrder() throws Exception { + List log = new CopyOnWriteArrayList<>(); + CountDownLatch latch = new CountDownLatch(2); + + try (OpenApiServer server = + baseBuilder() + .handlers( + Map.of( + OK_OPERATION_ID, + req -> { + req.afterResponse( + () -> { + log.add("first"); + latch.countDown(); + }); + req.afterResponse( + () -> { + log.add("second"); + latch.countDown(); + }); + return Response.status(HTTP_NO_CONTENT); + })) + .build()) { + + HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder(uri(server, OK_PATH)).GET().build(), + BodyHandlers.discarding()); + + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(log).containsExactly("first", "second"); + } + } + + @Test + void hookExceptionDoesNotAffectClientOrOtherHooks() throws Exception { + List log = new CopyOnWriteArrayList<>(); + CountDownLatch latch = new CountDownLatch(1); + + try (OpenApiServer server = + baseBuilder() + .handlers(Map.of(OK_OPERATION_ID, req -> Response.status(HTTP_NO_CONTENT))) + .afterResponseHook( + (req, resp) -> { + throw new RuntimeException("boom"); + }) + .afterResponseHook( + (req, resp) -> { + log.add("second-ran"); + latch.countDown(); + }) + .build()) { + + HttpResponse resp = + HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder(uri(server, OK_PATH)).GET().build(), + BodyHandlers.discarding()); + + assertThat(resp.statusCode()).isEqualTo(HTTP_NO_CONTENT); + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(log).containsExactly("second-ran"); + } + } + + @Test + void hookFiresOnHandlerException() throws Exception { + AtomicReference capturedResponse = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + + try (OpenApiServer server = + baseBuilder() + .handlers( + Map.of( + OK_OPERATION_ID, + req -> { + throw new RuntimeException("kapow"); + })) + .afterResponseHook( + (req, resp) -> { + capturedResponse.set(resp); + latch.countDown(); + }) + .build()) { + + HttpResponse resp = + HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder(uri(server, OK_PATH)).GET().build(), + BodyHandlers.discarding()); + + assertThat(resp.statusCode()).isEqualTo(HTTP_INTERNAL_ERROR); + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(capturedResponse.get()).isNotNull(); + assertThat(capturedResponse.get().status()).isEqualTo(HTTP_INTERNAL_ERROR); + assertThat(capturedResponse.get().body()).isNull(); + } + } + + @Test + void preRequestFailureSkipsHooks() throws Exception { + List log = new CopyOnWriteArrayList<>(); + + try (OpenApiServer server = + baseBuilder() + .handlers(Map.of(OK_OPERATION_ID, req -> Response.status(HTTP_NO_CONTENT))) + .afterResponseHook((req, resp) -> log.add("fired")) + .build()) { + + HttpResponse resp = + HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder(uri(server, NOT_FOUND_PATH)).GET().build(), + BodyHandlers.discarding()); + + assertThat(resp.statusCode()).isEqualTo(HTTP_NOT_FOUND); + // Give the server-side thread a moment to complete any potential async work + Thread.sleep(100); + assertThat(log).isEmpty(); + } + } + + @Test + void hookSeesScopedRequest() throws Exception { + AtomicReference hookScopedRequest = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + + try (OpenApiServer server = + baseBuilder() + .handlers(Map.of(OK_OPERATION_ID, req -> Response.status(HTTP_NO_CONTENT))) + .afterResponseHook( + (req, resp) -> { + hookScopedRequest.set(DispatchHandler.CURRENT.get()); + latch.countDown(); + }) + .build()) { + + HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder(uri(server, OK_PATH)).GET().build(), + BodyHandlers.discarding()); + + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(hookScopedRequest.get()).isNotNull(); + assertThat(hookScopedRequest.get().operationId()).isEqualTo(OK_OPERATION_ID); + } + } +} From c7cd9334e5fc31f76776d3879a7136e010c6f844 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 13:50:52 +0200 Subject: [PATCH 10/14] test: Assert hook runs on handler's thread --- .../com/retailsvc/http/AfterResponseHookIT.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/retailsvc/http/AfterResponseHookIT.java b/src/test/java/com/retailsvc/http/AfterResponseHookIT.java index fc0175d..bac248e 100644 --- a/src/test/java/com/retailsvc/http/AfterResponseHookIT.java +++ b/src/test/java/com/retailsvc/http/AfterResponseHookIT.java @@ -212,16 +212,25 @@ void preRequestFailureSkipsHooks() throws Exception { } @Test - void hookSeesScopedRequest() throws Exception { + void hookSeesScopedRequestAndSameThreadAsHandler() throws Exception { AtomicReference hookScopedRequest = new AtomicReference<>(); + AtomicReference handlerThread = new AtomicReference<>(); + AtomicReference hookThread = new AtomicReference<>(); CountDownLatch latch = new CountDownLatch(1); try (OpenApiServer server = baseBuilder() - .handlers(Map.of(OK_OPERATION_ID, req -> Response.status(HTTP_NO_CONTENT))) + .handlers( + Map.of( + OK_OPERATION_ID, + req -> { + handlerThread.set(Thread.currentThread()); + return Response.status(HTTP_NO_CONTENT); + })) .afterResponseHook( (req, resp) -> { hookScopedRequest.set(DispatchHandler.CURRENT.get()); + hookThread.set(Thread.currentThread()); latch.countDown(); }) .build()) { @@ -234,6 +243,7 @@ void hookSeesScopedRequest() throws Exception { assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); assertThat(hookScopedRequest.get()).isNotNull(); assertThat(hookScopedRequest.get().operationId()).isEqualTo(OK_OPERATION_ID); + assertThat(hookThread.get()).isSameAs(handlerThread.get()); } } } From 464f058c85ba1d6224226f678db6f3cb09ef8970 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 13:55:06 +0200 Subject: [PATCH 11/14] test: Builder.afterResponseHook null-guard --- .../java/com/retailsvc/http/OpenApiServerBuilderTest.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java b/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java index 1808abf..f0f1bc2 100644 --- a/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java +++ b/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java @@ -125,6 +125,12 @@ void bodyMapperRejectsNullMapper() { .isInstanceOf(NullPointerException.class); } + @Test + void afterResponseHookRejectsNull() { + OpenApiServer.Builder b = OpenApiServer.builder(); + assertThatThrownBy(() -> b.afterResponseHook(null)).isInstanceOf(NullPointerException.class); + } + private static Spec testSpec() { Map raw = Map.of( From 727ce7540b7432de1df5f7d913529c147178c638 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 13:56:25 +0200 Subject: [PATCH 12/14] docs: Document after-response hooks --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.md b/README.md index ee7d0df..082c284 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,33 @@ public class GetPromotionHandler implements RequestHandler { } ``` +### After-response hooks + +Register code to run after the response has been sent. Hooks run on the request virtual thread, +inside the library's request scope, with exceptions swallowed. + +``` java +OpenApiServer.builder() + .spec(spec) + .handlers(handlers) + .afterResponseHook((req, resp) -> + metrics.timer("http.request").record(req.operationId(), resp.status())) + .build(); +``` + +Handlers can also queue per-request runnables: + +``` java +Map handlers = Map.of( + "getThings", req -> { + req.afterResponse(() -> auditLog.flush()); + return Response.ok(things); + }); +``` + +Global hooks run first (registration order), then per-request runnables (FIFO). Pre-request +failures (404, 405, validation) do not fire hooks. + ### End-to-end example Gson on the classpath for request/response JSON, SnakeYAML on the classpath for the spec, one interceptor binding a request-scoped tenant + correlation id, one decorator stamping the correlation id on every response, one handler. No extra wiring. From 507aca25d6eb7eb67923610894c168f97d5ed742 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 14:08:08 +0200 Subject: [PATCH 13/14] fix: Address final-review feedback for after-response hooks --- README.md | 2 +- src/main/java/com/retailsvc/http/Request.java | 6 +++- .../retailsvc/http/AfterResponseHookIT.java | 31 +++++++++++++++++-- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 082c284..29fa78e 100644 --- a/README.md +++ b/README.md @@ -368,7 +368,7 @@ Map handlers = Map.of( ``` Global hooks run first (registration order), then per-request runnables (FIFO). Pre-request -failures (404, 405, validation) do not fire hooks. +failures (404, 405, validation) do not fire hooks. On the error path (when a handler throws), `Response#body()` is `null` and the bytes have already been streamed; use `Response#status()` to detect errors. ### End-to-end example diff --git a/src/main/java/com/retailsvc/http/Request.java b/src/main/java/com/retailsvc/http/Request.java index d5dc0f3..13d0404 100644 --- a/src/main/java/com/retailsvc/http/Request.java +++ b/src/main/java/com/retailsvc/http/Request.java @@ -337,7 +337,11 @@ public void afterResponse(Runnable runnable) { afterHooks.add(runnable); } - /** Internal accessor for the after-hook queue; used by RequestPreparationFilter. */ + /** + * Returns an unmodifiable view of the queued after-response runnables. Intended for the framework + * runner; consumers should use {@link #afterResponse(Runnable)} to register runnables rather than + * inspecting this list directly. + */ public List afterHooks() { return Collections.unmodifiableList(afterHooks); } diff --git a/src/test/java/com/retailsvc/http/AfterResponseHookIT.java b/src/test/java/com/retailsvc/http/AfterResponseHookIT.java index bac248e..7f35874 100644 --- a/src/test/java/com/retailsvc/http/AfterResponseHookIT.java +++ b/src/test/java/com/retailsvc/http/AfterResponseHookIT.java @@ -205,8 +205,6 @@ void preRequestFailureSkipsHooks() throws Exception { BodyHandlers.discarding()); assertThat(resp.statusCode()).isEqualTo(HTTP_NOT_FOUND); - // Give the server-side thread a moment to complete any potential async work - Thread.sleep(100); assertThat(log).isEmpty(); } } @@ -246,4 +244,33 @@ void hookSeesScopedRequestAndSameThreadAsHandler() throws Exception { assertThat(hookThread.get()).isSameAs(handlerThread.get()); } } + + @Test + void hookFiresWhenHandlerIsMissing() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference capturedResponse = new AtomicReference<>(); + + try (OpenApiServer server = + baseBuilder() + .handlers(Map.of()) // no handler registered for OK_OPERATION_ID + .afterResponseHook( + (req, resp) -> { + capturedResponse.set(resp); + latch.countDown(); + }) + .build()) { + + HttpResponse resp = + HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder(uri(server, OK_PATH)).GET().build(), + BodyHandlers.discarding()); + + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(resp.statusCode()).isEqualTo(HTTP_INTERNAL_ERROR); + assertThat(capturedResponse.get()).isNotNull(); + assertThat(capturedResponse.get().status()).isEqualTo(HTTP_INTERNAL_ERROR); + assertThat(capturedResponse.get().body()).isNull(); + } + } } From 3d89560bd585f1e59406f53e124a00010c186dc3 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 14:20:23 +0200 Subject: [PATCH 14/14] fix: Address SonarCloud findings (catch Exception, drop unused import) --- .../com/retailsvc/http/internal/RequestPreparationFilter.java | 4 ++-- .../retailsvc/http/internal/RequestPreparationFilterTest.java | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java index dfa9d12..f9283dc 100644 --- a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java +++ b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java @@ -143,14 +143,14 @@ private void fireAfterHooks(HttpExchange exchange, Request request) { for (AfterResponseHook hook : afterHooks) { try { hook.after(request, response); - } catch (Throwable t) { + } catch (Exception t) { LOG.debug("after-response hook threw", t); } } for (Runnable runnable : snapshot) { try { runnable.run(); - } catch (Throwable t) { + } catch (Exception t) { LOG.debug("after-response runnable threw", t); } } diff --git a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java index 2c4b359..d37ee9c 100644 --- a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java +++ b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java @@ -27,7 +27,6 @@ import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpExchange; import java.io.ByteArrayInputStream; -import java.io.IOException; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.List;