|
| 1 | +# BodyReader and RequestHandler |
| 2 | + |
| 3 | +**Date:** 2026-05-13 |
| 4 | +**Status:** Approved, ready for plan |
| 5 | + |
| 6 | +## Motivation |
| 7 | + |
| 8 | +The library currently hardcodes body parsing inside `RequestPreparationFilter`: a |
| 9 | +`switch` on media type dispatches to `FormUrlEncodedParser`, `TextPlainParser`, |
| 10 | +or the user-supplied `JsonMapper`. Adding a new media type (XML, CBOR, etc.) |
| 11 | +requires editing the filter. The `JsonMapper` name is also misleading once we |
| 12 | +treat it as one mapper among several. |
| 13 | + |
| 14 | +Handlers today receive a raw JDK `HttpExchange` and pull request data via |
| 15 | +static accessors on `Request` backed by a `ScopedValue<RequestContext>`. The |
| 16 | +ScopedValue exists only because `HttpHandler.handle(HttpExchange)` has nowhere |
| 17 | +else to carry prepared data. That side channel is unnecessary if handlers |
| 18 | +receive their own per-request object directly. |
| 19 | + |
| 20 | +This change introduces two interfaces — `BodyReader` for pluggable parsing and |
| 21 | +`RequestHandler` for handlers that receive a `Request` instead of an |
| 22 | +`HttpExchange` — and folds response writing into `Request` as a fluent |
| 23 | +gateway with one-shot and streaming terminals. |
| 24 | + |
| 25 | +## Scope |
| 26 | + |
| 27 | +In scope: |
| 28 | + |
| 29 | +- `BodyReader` interface and per-media-type registration on the builder. |
| 30 | +- Rename `JsonMapper` → the JSON `BodyReader`; default form and text readers |
| 31 | + wired automatically. |
| 32 | +- New `RequestHandler` interface; `handlers(...)` builder method changed to |
| 33 | + `Map<String, RequestHandler>` (breaking). |
| 34 | +- `Request` repurposed from a static-accessor utility into the per-request |
| 35 | + handle handlers receive. Read API mirrors today's `RequestContext`; adds a |
| 36 | + response gateway with one-shot and streaming terminals. |
| 37 | +- Internal `RequestContext` record and public `Request.CONTEXT` `ScopedValue` |
| 38 | + removed. |
| 39 | +- A required `jsonWriter(Object → byte[])` on the builder, used by |
| 40 | + `Request.respond(...).json(...)`. Generalising to `BodyWriter`s is a future |
| 41 | + change. |
| 42 | + |
| 43 | +Out of scope: |
| 44 | + |
| 45 | +- **Request streaming.** Handlers buffer the request body and validate it |
| 46 | + against the spec, as today. Streaming requests will be a follow-up; it needs |
| 47 | + a separate decision about how operations opt out of body validation. |
| 48 | +- A general `BodyWriter` abstraction symmetric to `BodyReader`. Response |
| 49 | + serialization for non-JSON content types is the handler's responsibility via |
| 50 | + `respond(...).bytes(...)` / `.text(...)` / `.stream(...)`. |
| 51 | + |
| 52 | +## Design |
| 53 | + |
| 54 | +### `BodyReader` |
| 55 | + |
| 56 | +```java |
| 57 | +package com.retailsvc.http; |
| 58 | + |
| 59 | +@FunctionalInterface |
| 60 | +public interface BodyReader { |
| 61 | + Object readFrom(byte[] body, String contentTypeHeader); |
| 62 | +} |
| 63 | +``` |
| 64 | + |
| 65 | +`contentTypeHeader` is the full raw `Content-Type` header — required so form |
| 66 | +and text readers can resolve `charset` and other parameters. JSON readers |
| 67 | +ignore it. |
| 68 | + |
| 69 | +`BodyReader` is schema-free. Today `FormUrlEncodedParser.parseAndCoerce` takes |
| 70 | +the body `Schema` to coerce field values; that coercion moves into the |
| 71 | +existing validator path that already coerces query/path/header parameters, so |
| 72 | +the form reader becomes a plain `byte[]` → `Map<String,Object>` step. |
| 73 | + |
| 74 | +### Builder registration |
| 75 | + |
| 76 | +```java |
| 77 | +OpenApiServer.builder() |
| 78 | + .spec(spec) |
| 79 | + .bodyReader("application/json", jsonReader) // required (no default) |
| 80 | + .bodyReader("application/xml", xmlReader) // optional extra |
| 81 | + .jsonWriter(jsonWriter) // required |
| 82 | + .handlers(Map.of("op", request -> { ... })) |
| 83 | + .build(); |
| 84 | +``` |
| 85 | + |
| 86 | +Defaults wired by the builder unless overridden: |
| 87 | + |
| 88 | +- `application/x-www-form-urlencoded` → built-in form reader. |
| 89 | +- `text/plain` → built-in text reader. |
| 90 | +- `application/json` → **no default**; the user must supply one (mirrors the |
| 91 | + current contract that `jsonMapper(...)` is required). |
| 92 | + |
| 93 | +Lookup: case-insensitive on the media-type subtype (existing |
| 94 | +`ContentTypeHeader.mediaType` already lowercases). No wildcard matching |
| 95 | +(`text/*`, `*/*`) — out of scope. |
| 96 | + |
| 97 | +### `Request` |
| 98 | + |
| 99 | +`com.retailsvc.http.Request` becomes the per-request handle. Concrete final |
| 100 | +class (no interface — YAGNI; extract later if testability demands it). |
| 101 | + |
| 102 | +```java |
| 103 | +public final class Request { |
| 104 | + // read API — same data RequestContext exposes today |
| 105 | + public byte[] bytes(); |
| 106 | + public Object parsed(); |
| 107 | + public String operationId(); |
| 108 | + public Map<String, String> pathParams(); |
| 109 | + |
| 110 | + // small conveniences |
| 111 | + public String header(String name); |
| 112 | + public Map<String, String> queryParams(); // parsed lazily, cached |
| 113 | + |
| 114 | + // response gateway |
| 115 | + public ResponseBuilder respond(int status); |
| 116 | +} |
| 117 | +``` |
| 118 | + |
| 119 | +`ResponseBuilder` (fluent; exactly one terminal call per `Request`): |
| 120 | + |
| 121 | +```java |
| 122 | +public interface ResponseBuilder { |
| 123 | + ResponseBuilder header(String name, String value); |
| 124 | + ResponseBuilder contentType(String contentType); // shorthand |
| 125 | + |
| 126 | + // one-shot terminals |
| 127 | + void empty(); // sendResponseHeaders(status, -1) |
| 128 | + void bytes(byte[] body); // sendResponseHeaders(status, body.length) |
| 129 | + void text(String body); // utf-8; sets Content-Type if unset |
| 130 | + void json(Object body); // jsonWriter; sets Content-Type if unset |
| 131 | + void problem(ProblemDetail pd); // application/problem+json |
| 132 | + |
| 133 | + // streaming terminals |
| 134 | + OutputStream stream(); // chunked; sendResponseHeaders(status, 0) |
| 135 | + OutputStream stream(long length); // known length |
| 136 | +} |
| 137 | +``` |
| 138 | + |
| 139 | +State machine, enforced via `IllegalStateException`: |
| 140 | + |
| 141 | +- exactly one terminal call per `Request`; |
| 142 | +- `header(...)` / `contentType(...)` only before the terminal call; |
| 143 | +- streaming terminals return an `OutputStream` the handler is responsible for |
| 144 | + closing (the framework also closes it as a safety net when the exchange ends). |
| 145 | + |
| 146 | +Empty bodies use `responseLength = -1` per the existing project convention |
| 147 | +(0 triggers chunked encoding). |
| 148 | + |
| 149 | +### `RequestHandler` |
| 150 | + |
| 151 | +```java |
| 152 | +@FunctionalInterface |
| 153 | +public interface RequestHandler { |
| 154 | + void handle(Request request) throws IOException; |
| 155 | +} |
| 156 | +``` |
| 157 | + |
| 158 | +`IOException` is kept on the signature for response-writing I/O. Unchecked |
| 159 | +exceptions continue to flow into the existing `ExceptionFilter` → |
| 160 | +`ExceptionHandler` path unchanged. |
| 161 | + |
| 162 | +### Builder shape |
| 163 | + |
| 164 | +```java |
| 165 | +OpenApiServer.builder() |
| 166 | + .spec(spec) |
| 167 | + .bodyReader(String mediaType, BodyReader reader) |
| 168 | + .jsonWriter(Function<Object, byte[]> writer) |
| 169 | + .handlers(Map<String, RequestHandler> handlers) // type changed (breaking) |
| 170 | + .addHandler(String path, HttpHandler extra) // unchanged — raw HttpHandler |
| 171 | + .exceptionHandler(...) |
| 172 | + .port(...) |
| 173 | + .shutdownTimeoutSeconds(...) |
| 174 | + .build(); |
| 175 | +``` |
| 176 | + |
| 177 | +`addHandler(path, HttpHandler)` for extras stays raw — extras are arbitrary |
| 178 | +side paths (health, metrics) that don't go through OpenAPI dispatch and don't |
| 179 | +benefit from `Request`. |
| 180 | + |
| 181 | +### Filter → dispatcher handoff |
| 182 | + |
| 183 | +`RequestPreparationFilter` reads the body, runs validation, and builds the |
| 184 | +`Request` object (including the parsed body, path params, operation ID, and a |
| 185 | +reference to the `HttpExchange`). It hands the `Request` to `DispatchHandler` |
| 186 | +via an internal, package-private `ScopedValue<Request>`. |
| 187 | + |
| 188 | +The user-visible `Request.CONTEXT` `ScopedValue` and the static |
| 189 | +`Request.bytes()` / `.parsed()` / `.operationId()` / `.pathParams()` accessors |
| 190 | +are removed. The internal `RequestContext` record is removed. |
| 191 | + |
| 192 | +`DispatchHandler` becomes: |
| 193 | + |
| 194 | +```java |
| 195 | +final class DispatchHandler implements HttpHandler { |
| 196 | + static final ScopedValue<Request> CURRENT = ScopedValue.newInstance(); |
| 197 | + private final Map<String, RequestHandler> handlers; |
| 198 | + |
| 199 | + @Override |
| 200 | + public void handle(HttpExchange exchange) throws IOException { |
| 201 | + Request request = CURRENT.get(); |
| 202 | + RequestHandler h = handlers.get(request.operationId()); |
| 203 | + if (h == null) { |
| 204 | + throw new MissingOperationHandlerException(request.operationId()); |
| 205 | + } |
| 206 | + h.handle(request); |
| 207 | + } |
| 208 | +} |
| 209 | +``` |
| 210 | + |
| 211 | +## Breaking changes |
| 212 | + |
| 213 | +This is a pre-1.0 library; breaking changes are acceptable. |
| 214 | + |
| 215 | +- `JsonMapper` removed; replaced by `BodyReader`. The builder method |
| 216 | + `jsonMapper(JsonMapper)` becomes `bodyReader("application/json", BodyReader)`. |
| 217 | +- Builder method `handlers(Map<String, HttpHandler>)` becomes |
| 218 | + `handlers(Map<String, RequestHandler>)`. |
| 219 | +- Static accessors `Request.bytes()` / `Request.parsed()` / |
| 220 | + `Request.operationId()` / `Request.pathParams()` / `Request.current()` and |
| 221 | + the `Request.CONTEXT` `ScopedValue` are removed. Handlers read this data |
| 222 | + from the `Request` parameter. |
| 223 | +- The example launcher under `src/test/java/.../start/` is updated as part of |
| 224 | + this change. |
| 225 | + |
| 226 | +## Testing |
| 227 | + |
| 228 | +Existing integration tests (`*IT.java`) exercise the full stack and will be |
| 229 | +updated to use the new handler signature. Unit tests cover: |
| 230 | + |
| 231 | +- `BodyReader` registration: defaults wired, user overrides win, missing |
| 232 | + `application/json` reader fails the builder. |
| 233 | +- `Request` read API: byte/parsed/operationId/pathParams round-trip. |
| 234 | +- `Request` response gateway: each terminal produces the right |
| 235 | + `sendResponseHeaders` length and `Content-Type`; double-terminal throws |
| 236 | + `IllegalStateException`; `header(...)` after terminal throws. |
| 237 | +- Streaming terminals: `stream()` uses chunked encoding (length 0); |
| 238 | + `stream(length)` uses the supplied length. |
| 239 | +- Form-coercion moved out of `FormUrlEncodedParser` — existing form-body |
| 240 | + validation tests must still pass. |
| 241 | + |
| 242 | +## Migration order |
| 243 | + |
| 244 | +The implementation plan will sequence this as: |
| 245 | + |
| 246 | +1. Introduce `BodyReader`; keep `JsonMapper` as a deprecated adapter. |
| 247 | +2. Move form-coercion out of `FormUrlEncodedParser` into the validator path. |
| 248 | +3. Build the new `Request` class (read API + response gateway) and the internal `ScopedValue<Request>` handoff; keep the old static `Request` accessors alive temporarily. |
| 249 | +4. Introduce `RequestHandler`; update the builder; update example launcher and tests. |
| 250 | +5. Remove `JsonMapper`, the static `Request` accessors, and the `RequestContext` record. |
0 commit comments