Skip to content

Commit fd6c003

Browse files
authored
feat: Refactor to hide Java HttpHandler interface behind own interface (#75)
1 parent f24ea84 commit fd6c003

31 files changed

Lines changed: 2753 additions & 541 deletions

docs/superpowers/plans/2026-05-20-extra-route-interface.md

Lines changed: 1337 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
# Extra-route interface, ExceptionHandler decoupling, and problem+json via registered TypeMapper
2+
3+
Date: 2026-05-20
4+
5+
## Problem
6+
7+
The library's stated goal is to wrap `com.sun.net.httpserver.HttpServer` behind transport-neutral abstractions (`Request`, `Response`, `RequestHandler`). Today three public API surfaces still leak the JDK types:
8+
9+
1. `OpenApiServer.Builder.extraRoute(String, com.sun.net.httpserver.HttpHandler)` — the registration point for routes that bypass OpenAPI (alive / health / spec endpoints).
10+
2. `Handlers.aliveHandler()`, `healthHandler(...)`, `specHandler(...)`, `notFoundHandler()` — return `HttpHandler`.
11+
3. `ExceptionHandler.handle(HttpExchange, Throwable)` — exposes `HttpExchange` to user code that defines a custom error mapper.
12+
13+
This couples the public API to the JDK server and blocks a future swap to a different transport (Netty, Helidon Níma, etc.).
14+
15+
Separately, `internal/ProblemDetailRenderer` hand-rolls JSON for RFC 7807 problem+json bodies, with bespoke escape logic for quotes, backslashes, control characters, etc. The comment justifies this with "Hand-rolled to avoid pulling in a JSON library", but a JSON `TypeMapper` is now mandatory on every `OpenApiServer` (user-supplied, or auto-detected via the Gson fallback), so the hand-rolled path is no longer justified — and it's an unmaintained second escaper that can drift from the real one.
16+
17+
## Goals
18+
19+
- Remove every `com.sun.net.httpserver` import from `com.retailsvc.http.*` (excluding `com.retailsvc.http.internal.*`).
20+
- Express extra routes through the same `RequestHandler` / `Request` / `Response` triple already used for OpenAPI operations.
21+
- Express exception mapping as `Throwable → Response`, rendered by the framework.
22+
- Delete `ProblemDetailRenderer`; serialize problem+json bodies through the registered JSON `TypeMapper`.
23+
24+
## Non-goals
25+
26+
- Path templating for extra routes (still exact match).
27+
- OpenAPI validation for extra routes (still bypassed).
28+
- Body parsing through `TypeMapper` for extra routes (handlers see raw bytes via `Request.bytes()`).
29+
- Migrating the `internal/*` filters and handlers off `HttpExchange` — they are the transport adapter and stay coupled by design.
30+
31+
## Design
32+
33+
### 1. `Request` gains `method()`
34+
35+
Add an `HttpMethod method` field and accessor to `com.retailsvc.http.Request`. The existing `com.retailsvc.http.spec.HttpMethod` enum is reused (already public, already covers GET/POST/PUT/DELETE/PATCH/HEAD/OPTIONS/TRACE/CONNECT, and is used by `Router` and `MethodNotAllowedException`).
36+
37+
Construction sites:
38+
39+
- `RequestPreparationFilter` — already parses `HttpMethod method = HttpMethod.parse(exchange.getRequestMethod())` before routing; pass it into the `Request` constructor.
40+
- New `ExtraRouteAdapter` (see §3) — passes its own parsed method.
41+
42+
The 8-arg `Request` constructor grows to 9-arg. The existing `@SuppressWarnings("java:S107")` annotation already documents that this parameter list is intentionally flat at the adapter boundary; that justification still applies.
43+
44+
`withPrincipals` is updated to thread `method` through.
45+
46+
### 2. `extraRoute` accepts `RequestHandler`
47+
48+
```java
49+
public Builder extraRoute(String path, RequestHandler handler) { ... }
50+
```
51+
52+
The old `HttpHandler` overload is removed (not deprecated). Pre-1.0; only internal callers (the `Handlers.*` factories) and tests use it, and they all migrate in the same PR.
53+
54+
The `Builder.extras` field becomes `LinkedHashMap<String, RequestHandler>`.
55+
`HandlerConfig.extras` follows.
56+
57+
### 3. `ExtraRouteAdapter` (internal)
58+
59+
New `internal/ExtraRouteAdapter implements HttpHandler`. Bridges between the JDK transport and the user's `RequestHandler` for an extra path. For each registered extra, `OpenApiServer` wires:
60+
61+
```
62+
HttpContext extraCtx = httpServer.createContext(path);
63+
extraCtx.getFilters().add(new ExceptionFilter(exceptionHandler));
64+
extraCtx.setHandler(new ExtraRouteAdapter(handler, bodyMappers, renderer));
65+
```
66+
67+
On each request, `ExtraRouteAdapter.handle(exchange)`:
68+
69+
1. Reads body bytes (`exchange.getRequestBody().readAllBytes()`).
70+
2. Parses the method via `HttpMethod.parse(exchange.getRequestMethod())`.
71+
3. Constructs a `Request` with: body bytes, `parsed=null`, `bodyMapper=null`, `operationId=null`, `pathParameters=Map.of()`, `rawQuery`, `headerLookup`, `principals=Map.of()`, `method`.
72+
4. Calls `handler.handle(request)`.
73+
5. Renders the returned `Response` via the existing `ResponseRenderer` (the same renderer instance used for OpenAPI operations is reused — no behaviour drift between extras and operations).
74+
75+
Exceptions thrown by the handler propagate to the outer `ExceptionFilter` exactly like operation handlers do today, so the same `ExceptionHandler` is invoked.
76+
77+
`Response` features (streaming, byte body, JSON body, status-only, headers) all work for extras without code duplication because `ResponseRenderer` is shared.
78+
79+
### 4. `ExceptionHandler` returns a `Response`
80+
81+
```java
82+
@FunctionalInterface
83+
public interface ExceptionHandler {
84+
Response handle(Throwable t);
85+
}
86+
```
87+
88+
`ExceptionFilter` becomes:
89+
90+
```java
91+
public void doFilter(HttpExchange exchange, Chain chain) throws IOException {
92+
try {
93+
chain.doFilter(exchange);
94+
} catch (RuntimeException | IOException t) {
95+
Response response = handler.handle(t);
96+
renderer.render(exchange, response);
97+
}
98+
}
99+
```
100+
101+
`ExceptionFilter` is constructed with both the handler and the shared `ResponseRenderer`. (`renderer.render(exchange, response)` already exists for the normal path; reused here.)
102+
103+
`Handlers.defaultExceptionHandler(TypeMapper jsonMapper)` takes the registered JSON mapper and becomes:
104+
105+
```java
106+
public static ExceptionHandler defaultExceptionHandler(TypeMapper jsonMapper) {
107+
Objects.requireNonNull(jsonMapper, "jsonMapper");
108+
return t -> switch (t) {
109+
case ValidationException ve -> Response.bytes(
110+
HTTP_BAD_REQUEST,
111+
jsonMapper.writeTo(ProblemDetail.forValidation(ve.error())),
112+
"application/problem+json");
113+
case BadRequestException bre -> Response.bytes(
114+
bre.status(),
115+
jsonMapper.writeTo(ProblemDetail.forBadRequest(bre)),
116+
"application/problem+json");
117+
case NotFoundException _ -> Response.notFound();
118+
case MethodNotAllowedException mna -> Response.status(HTTP_BAD_METHOD)
119+
.withHeader("Allow", mna.allowed().stream()
120+
.map(Enum::name).collect(Collectors.joining(", ")));
121+
default -> {
122+
LOG.error("Unhandled exception in handler", t);
123+
yield Response.status(HTTP_INTERNAL_ERROR);
124+
}
125+
};
126+
}
127+
```
128+
129+
`OpenApiServer.Builder.build()` supplies the resolved JSON mapper when no custom `ExceptionHandler` is set (the JSON mapper has already been resolved at that point via `resolveBodyMappers`).
130+
131+
Rationale: the exception path runs before a `Request` is necessarily built (e.g. a malformed URI in `RequestPreparationFilter` itself), so the handler signature cannot take `Request` — the simplest pre-`Request` signature is the right one.
132+
133+
### 4a. New public `BadRequestException`
134+
135+
User handlers need a way to reject a syntactically-valid-but-semantically-wrong request (e.g. "email already taken" → 422 Unprocessable Content, "stale ETag" → 412 Precondition Failed) and have the framework render a problem+json response — without each handler re-implementing the problem-detail wire shape.
136+
137+
New public class:
138+
139+
```java
140+
package com.retailsvc.http;
141+
142+
public final class BadRequestException extends RuntimeException {
143+
144+
private static final int DEFAULT_STATUS = 400;
145+
146+
private final int status;
147+
private final String pointer; // nullable
148+
private final String keyword; // nullable
149+
150+
public BadRequestException(String detail) {
151+
this(DEFAULT_STATUS, detail, null, null);
152+
}
153+
154+
public BadRequestException(int status, String detail) {
155+
this(status, detail, null, null);
156+
}
157+
158+
public BadRequestException(int status, String detail, String pointer, String keyword) {
159+
super(Objects.requireNonNull(detail, "detail must not be null"));
160+
if (status < 400 || status > 499) {
161+
throw new IllegalArgumentException("status must be 4xx, got " + status);
162+
}
163+
this.status = status;
164+
this.pointer = pointer;
165+
this.keyword = keyword;
166+
}
167+
168+
public int status() { return status; }
169+
public Optional<String> pointer() { return Optional.ofNullable(pointer); }
170+
public Optional<String> keyword() { return Optional.ofNullable(keyword); }
171+
}
172+
```
173+
174+
Design points:
175+
176+
- **Optional status, default 400.** The no-status constructor preserves the common "client sent something bad" case at zero ceremony; the overload accepts any 4xx for 409/412/422/429 etc.
177+
- **4xx range enforced.** Throwing at construction prevents accidentally surfacing a 500 through a "bad request" path. 5xx errors should propagate as ordinary `RuntimeException` and hit the default 500 branch.
178+
- **Optional pointer/keyword** mirror `ValidationError` so the problem+json document shape is consistent whether the body was rejected by the OpenAPI validator (`ValidationException`) or by handler-level domain rules (`BadRequestException`).
179+
- **`detail`** uses `super(message)` so standard exception machinery (logging, stack traces) sees the human-readable reason.
180+
181+
### 4b. `ProblemDetail` record replaces `ProblemDetailRenderer`
182+
183+
New `internal/ProblemDetail` record (or public if we want users to be able to return problem+json from handlers — TBD; start internal):
184+
185+
```java
186+
record ProblemDetail(
187+
String type, String title, int status, String detail,
188+
String pointer, String keyword) {
189+
190+
static ProblemDetail forValidation(ValidationError e) {
191+
return new ProblemDetail(
192+
"about:blank", "Bad Request", 400, e.message(), e.pointer(), e.keyword());
193+
}
194+
195+
static ProblemDetail forBadRequest(BadRequestException e) {
196+
return new ProblemDetail(
197+
"about:blank",
198+
titleFor(e.status()),
199+
e.status(),
200+
e.getMessage(),
201+
e.pointer().orElse(null),
202+
e.keyword().orElse(null));
203+
}
204+
205+
// Small map of common 4xx codes to RFC-7231 reason phrases.
206+
// Unknown 4xx falls back to "Bad Request" — the type field is "about:blank",
207+
// so per RFC 7807 the title is advisory; the precise meaning rides on status.
208+
private static String titleFor(int status) { ... }
209+
}
210+
```
211+
212+
Serialization runs through whichever JSON `TypeMapper` the server is configured with (Gson by default, or Jackson if the user registered one). Field order and `null` handling are whatever the mapper produces — Gson and Jackson both emit fields in declaration order and skip nulls when configured; field-presence is asserted in tests against the actual configured mapper rather than a hand-rolled string.
213+
214+
`ProblemDetailRenderer` is deleted along with its bespoke escape logic.
215+
216+
### 5. `Handlers.*` factories migrate
217+
218+
Return types change from `HttpHandler` to `RequestHandler`. The 405-on-non-GET/HEAD check, previously a `MethodLimitedHandler` wrapper, is inlined in each factory:
219+
220+
```java
221+
public static RequestHandler aliveHandler() {
222+
return req -> switch (req.method()) {
223+
case GET, HEAD -> Response.empty();
224+
default -> Response.status(HTTP_BAD_METHOD)
225+
.withHeader("Allow", "GET, HEAD");
226+
};
227+
}
228+
```
229+
230+
`healthHandler(TypeMapper, Supplier<HealthOutcome>)` and `specHandler(String)` follow the same shape. For `specHandler`, `HEAD` returns `Response.bytes(200, new byte[0], contentType).withHeader("Content-Length", String.valueOf(bytes.length))` to preserve the existing HEAD-omits-body behaviour.
231+
232+
`Handlers.notFoundHandler()` is dropped from the public API and moved to `internal/NotFoundHandler` (the framework's catch-all `/` context is its only caller).
233+
234+
`MethodLimitedHandler` (internal) is deleted.
235+
236+
### 6. Public API surface after the change
237+
238+
`com.retailsvc.http.*` (excluding `internal.*`) contains zero references to `com.sun.net.httpserver`. Grep verification:
239+
240+
```
241+
grep -rn "com\.sun\.net\.httpserver" src/main/java/com/retailsvc/http/ \
242+
| grep -v "/internal/"
243+
```
244+
245+
returns no results.
246+
247+
## Migration steps
248+
249+
1. Add `method` to `Request`; update `RequestPreparationFilter` to pass it.
250+
2. Introduce `ExtraRouteAdapter`; switch `OpenApiServer` extras wiring to it.
251+
3. Change `Builder.extraRoute` signature; update `HandlerConfig` and tests.
252+
4. Change `ExceptionHandler` signature; update `ExceptionFilter` to accept a `ResponseRenderer`; rewrite `Handlers.defaultExceptionHandler(TypeMapper)` and wire it from `Builder.build()`.
253+
5. Add public `BadRequestException`; wire a `case` for it into the default exception handler.
254+
6. Add `internal/ProblemDetail`; delete `internal/ProblemDetailRenderer`.
255+
7. Migrate `Handlers.aliveHandler/healthHandler/specHandler` to `RequestHandler` with inline 405 checks.
256+
8. Move `Handlers.notFoundHandler` to `internal/NotFoundHandler`.
257+
9. Delete `MethodLimitedHandler`.
258+
10. Update tests: `OpenApiServerBuilderTest`, `ExtraHandlersIT`, `HandlersTest` (if present), exception-handler tests (problem+json wire-shape now asserted as parsed JSON, not byte-equality), integration tests, plus new tests for `BadRequestException` (default 400, custom status, 4xx-range guard, pointer/keyword propagation to problem+json).
259+
260+
## Test plan
261+
262+
- Existing integration tests for extra routes (`ExtraHandlersIT`) pass unchanged behaviourally (paths still return the same bytes/status).
263+
- `OpenApiServerBuilderTest` covers the duplicate-path rule for the new signature.
264+
- New unit test: `ExtraRouteAdapter` constructs a `Request` with `operationId=null`, empty `pathParams`, empty `principals`, correct method, raw query, and body bytes; invokes the user handler; renders the response.
265+
- Default exception handler produces the same wire output for the four known exception classes as the current implementation (byte-for-byte for the problem+json case).
266+
- Custom exception handlers: regression test that user-supplied `ExceptionHandler` returning `Response.of(418, body)` is rendered.
267+
268+
## Risks
269+
270+
- **Behavioural drift in exception rendering.** Today the default handler writes headers and body directly via hand-rolled JSON. Routing through `Response`/`ResponseRenderer` and the registered JSON mapper changes both the code path and the exact bytes of the problem+json document (different mappers may differ in whitespace, null-omission, or field order). Mitigation: assert wire shape by parsing the response with the same mapper and comparing field-by-field, not byte-by-byte. Document that the exact byte output of problem+json depends on the registered JSON mapper.
271+
- **Pre-`Request` exceptions losing context.** The new `ExceptionHandler` signature has no `Request`. User code that wanted to log the request path on error must use logging in the handler itself. Acceptable: the default handler already does not use request context, and user custom handlers that need it can attach an `OpenApiServer.builder().interceptor(...)` to capture request info into an MDC.
272+
- **`Request.method()` non-null for OpenAPI handlers.** Since the OpenAPI router already dispatches by method, this is redundant for those handlers but consistent and cheap.
273+
274+
## Out of scope
275+
276+
- Removing `HttpExchange` from `internal/*`. The internal package is the transport adapter and intentionally coupled to the JDK server.
277+
- A swap-the-transport SPI. Even with this change shipped, swapping transports still requires reworking `internal/*` filters; the API surface, however, no longer blocks that work.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.retailsvc.http;
2+
3+
import java.util.Objects;
4+
import java.util.Optional;
5+
6+
/**
7+
* Thrown by user handlers to signal a 4xx client error. The default {@link ExceptionHandler}
8+
* renders this as an RFC 7807 {@code application/problem+json} response carrying the supplied
9+
* status, detail, and optional JSON-pointer / validation-keyword fields.
10+
*
11+
* <p>Use for cases like {@code 422 Unprocessable Content} (payload is syntactically valid but
12+
* violates a business rule), {@code 409 Conflict}, {@code 412 Precondition Failed}, etc. For 5xx
13+
* errors, throw an ordinary {@link RuntimeException} and let the default handler render 500.
14+
*/
15+
public final class BadRequestException extends RuntimeException {
16+
17+
private static final int DEFAULT_STATUS = 400;
18+
19+
private final int status;
20+
private final String pointer;
21+
private final String keyword;
22+
23+
public BadRequestException(String detail) {
24+
this(DEFAULT_STATUS, detail, null, null);
25+
}
26+
27+
public BadRequestException(int status, String detail) {
28+
this(status, detail, null, null);
29+
}
30+
31+
public BadRequestException(int status, String detail, String pointer, String keyword) {
32+
super(Objects.requireNonNull(detail, "detail must not be null"));
33+
if (status < 400 || status > 499) {
34+
throw new IllegalArgumentException("status must be 4xx, got " + status);
35+
}
36+
this.status = status;
37+
this.pointer = pointer;
38+
this.keyword = keyword;
39+
}
40+
41+
public int status() {
42+
return status;
43+
}
44+
45+
public Optional<String> pointer() {
46+
return Optional.ofNullable(pointer);
47+
}
48+
49+
public Optional<String> keyword() {
50+
return Optional.ofNullable(keyword);
51+
}
52+
}
Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
package com.retailsvc.http;
22

3-
import com.sun.net.httpserver.HttpExchange;
4-
import com.sun.net.httpserver.HttpHandler;
5-
import java.io.IOException;
6-
73
/**
8-
* Handle exceptions thrown from a {@link HttpHandler}.
4+
* Maps a {@link Throwable} thrown anywhere in the request pipeline to a {@link Response}.
95
*
10-
* @author thced
6+
* <p>Runs outside any {@code ScopedValue} bindings established by filters or interceptors — scopes
7+
* are torn down as the exception unwinds. Context-aware error mapping (trace IDs, etc.) should be
8+
* done in a {@link RequestInterceptor} that wraps {@code next.proceed()} in try/catch.
119
*/
1210
@FunctionalInterface
1311
public interface ExceptionHandler {
1412

15-
void handle(HttpExchange exchange, Throwable t) throws IOException;
13+
Response handle(Throwable t);
1614
}

0 commit comments

Comments
 (0)