|
| 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. |
0 commit comments