Skip to content

Commit b1287ee

Browse files
committed
docs: BodyReader and RequestHandler design
1 parent f316b7a commit b1287ee

1 file changed

Lines changed: 250 additions & 0 deletions

File tree

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
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

Comments
 (0)