Skip to content

Commit e41d0d7

Browse files
authored
feat!: TypeMapper + RequestHandler + transport-neutral Request/Response (#56)
Reshapes the handler API around value objects and pluggable body codecs, and decouples request handling from the JDK HttpServer transport. Sets up the codebase for richer OpenAPI 3.1 coverage (Wave 2+) and a future Netty/Helidon backend without further handler-side churn. Breaking changes - Handlers now implement RequestHandler (Request -> Response) instead of raw HttpHandler. ResponseBuilder is removed; Response is an immutable value object built via factories (ok, created, accepted, notFound, notImplemented, status, empty, problem, etc.). - JsonMapper is replaced by TypeMapper, registered per media-type via Builder.bodyMapper(mediaType, mapper) or the new Builder.jsonMapper(m) shortcut. Optional TypedTypeMapper enables Request.asPojo(Class). - Request.queryParam / Request.header return Optional<String> with blank values treated as absent. - Builder.addHandler renamed to extraRoute (path-keyed, non-OpenAPI routes; distinct from operationId-keyed handlers(Map)). - Response.created(body, location) overload removed — use Response.created(body).withHeader("Location", uri). - Request constructor takes transport-neutral primitives (body, parsed, TypeMapper, operationId, path params, raw query, header lookup) — no longer holds an HttpExchange. New - Built-in TypeMappers: FormTypeMapper (x-www-form-urlencoded), TextTypeMapper (text/plain), JacksonJsonTypeMapper, and an auto- registered GsonJsonMapper fallback (integer-preserving, JSR-310 as ISO-8601). Gson is now an optional compile dependency. - RequestInterceptor (around-advice, can short-circuit) and ResponseDecorator (post-handler header/body transforms). - Spec.fromPath(Path) with JSON/YAML auto-detect. - Convenience accessors: Request.pathParam, Request.contentType, Request.queryParams / rawQuery. - Closing HttpExchange after one-shot and streaming responses; one- terminal-per-Request enforced; responseLength=-1 for empty bodies. Internals - RequestPreparationFilter is the sole place that touches HttpExchange. - New ResponseRenderer, BodyWriter, FormBodyCoercion; removed LegacyRequestAccess and RequestContext. Docs - README rewritten around the new handler shape, TypeMapper choice, interceptors + decorators, end-to-end YAML example, and an updated Performance section explaining why Request is transport-neutral (Netty/Níma/Jetty backends are now structurally feasible).
1 parent f316b7a commit e41d0d7

55 files changed

Lines changed: 4618 additions & 670 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 269 additions & 70 deletions
Large diffs are not rendered by default.

docs/superpowers/plans/2026-05-13-type-mapper-request-handler.md

Lines changed: 1858 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
# TypeMapper 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, and the response side has no symmetric
13+
abstraction at all — handlers have to write bytes manually.
14+
15+
Handlers today receive a raw JDK `HttpExchange` and pull request data via
16+
static accessors on `Request` backed by a `ScopedValue<RequestContext>`. The
17+
ScopedValue exists only because `HttpHandler.handle(HttpExchange)` has nowhere
18+
else to carry prepared data. That side channel is unnecessary if handlers
19+
receive their own per-request object directly.
20+
21+
This change introduces two interfaces — `TypeMapper` for pluggable
22+
read/write per media type, and `RequestHandler` for handlers that receive a
23+
`Request` instead of an `HttpExchange` — and folds response writing into
24+
`Request` as a fluent gateway with one-shot and streaming terminals.
25+
26+
## Scope
27+
28+
In scope:
29+
30+
- `TypeMapper` interface (read + write) and per-media-type registration on the builder.
31+
- Delete `JsonMapper`; the user supplies a `TypeMapper` for `application/json` instead. Default form and text mappers wired automatically. Optional Gson-backed default for `application/json` activated when Gson is on the classpath and no user-supplied JSON mapper is registered.
32+
- New `RequestHandler` interface; `handlers(...)` builder method changed to `Map<String, RequestHandler>` (breaking).
33+
- `Request` repurposed from a static-accessor utility into the per-request handle handlers receive. Read API mirrors today's `RequestContext`; adds a response gateway with one-shot and streaming terminals.
34+
- Internal `RequestContext` record and public `Request.CONTEXT` `ScopedValue` removed.
35+
36+
Out of scope:
37+
38+
- **Request streaming.** Handlers buffer the request body and validate it against the spec, as today. Streaming requests will be a follow-up; it needs a separate decision about how operations opt out of body validation.
39+
- Wildcard media-type matching (`text/*`, `*/*`).
40+
41+
## Design
42+
43+
### `TypeMapper`
44+
45+
```java
46+
package com.retailsvc.http;
47+
48+
public interface TypeMapper {
49+
Object readFrom(byte[] body, String contentTypeHeader);
50+
byte[] writeTo(Object value);
51+
}
52+
```
53+
54+
`contentTypeHeader` on `readFrom` is the full raw `Content-Type` header — required so form and text mappers can resolve `charset` and other parameters. JSON mappers ignore it.
55+
56+
`TypeMapper` is schema-free. Today `FormUrlEncodedParser.parseAndCoerce` takes the body `Schema` to coerce field values; that coercion moves into the existing validator path that already coerces query/path/header parameters, so the form mapper becomes a plain `byte[]``Map<String,Object>` step on the read side.
57+
58+
### Built-in defaults
59+
60+
`TypeMapper` applies uniformly to every media type, including the built-ins. Defaults wired by the builder unless overridden:
61+
62+
- `application/x-www-form-urlencoded` — built-in form mapper. `readFrom` parses to `Map<String,Object>`. `writeTo` throws `UnsupportedOperationException`; form-encoded responses are unusual and we won't speculate on the encoding until someone needs it.
63+
- `text/plain` — built-in text mapper. `readFrom` decodes bytes using the charset declared on `Content-Type` (default UTF-8). `writeTo` returns `String.valueOf(value).getBytes(UTF_8)`.
64+
- `application/json`**no static default**; if the user does not register a mapper, the builder probes the classpath for Gson and falls back to a built-in Gson-backed mapper (see below). If Gson is not on the classpath either, `build()` fails with the same "no JSON mapper registered" error.
65+
66+
Lookup: case-insensitive on the media-type subtype (existing `ContentTypeHeader.mediaType` already lowercases).
67+
68+
### Optional Gson fallback for `application/json`
69+
70+
To shrink setup for callers that already use Gson, the library ships an internal Gson-backed `TypeMapper` and auto-registers it when:
71+
72+
1. The builder reaches `build()` and no `TypeMapper` has been registered for `application/json`; and
73+
2. `com.google.gson.Gson` is resolvable on the classpath.
74+
75+
Implementation:
76+
77+
- Gson is an **optional** Maven dependency (`<optional>true</optional>` / `provided`). The library does not pull Gson into consumer classpaths.
78+
- One internal class — `com.retailsvc.http.internal.gson.GsonJsonMapper` — imports Gson directly. The builder loads it reflectively (`Class.forName(...)`) only after probing for Gson, so consumers without Gson never trigger class-loading of that adapter and never see `NoClassDefFoundError`.
79+
- Jackson is **not** auto-detected. Jackson users register a `TypeMapper` explicitly. Auto-providing a default `ObjectMapper` would pick the wrong configuration for most Jackson users (modules, naming, date formats).
80+
81+
Number handling on read:
82+
83+
- Gson's default `fromJson(json, Object.class)` deserialises every JSON number as `Double`. The library's validator has `IntegerSchema`, format-width checks, and NaN/Infinity rejection that assume integral values arrive as `Long`/`Integer`. To avoid surprises, `GsonJsonMapper` is constructed with a custom `TypeAdapter<Object>` that:
84+
- reads integral JSON numbers (no fraction, no exponent producing a fraction) into `Long`;
85+
- reads non-integral or out-of-`Long`-range numbers into `Double`;
86+
- reads everything else (`String`, `Boolean`, `null`, arrays, objects) the way Gson's default does.
87+
- This is a well-known Gson pattern; ~30 lines, tested in isolation.
88+
89+
JSR-310 handling on write:
90+
91+
- The default `Gson` instance is built with `TypeAdapter`s for `Instant`, `OffsetDateTime`, `ZonedDateTime`, `LocalDateTime`, `LocalDate`, and `LocalTime`. Each adapter writes `value.toString()` — every JSR-310 type's `toString()` already emits ISO-8601, so adapters are ~5 lines each.
92+
- Without these adapters Gson's default would serialise these types using internal field values, which is never what handlers want.
93+
- Read direction is unaffected: the library parses bodies into raw `Object` (`Map<String,Object>` / `List<?>` / `String` / `Long` / `Double` / `Boolean` / `null`). Gson is never asked to construct an `Instant`, so an ISO-8601 datetime in incoming JSON stays a `String` and is validated against `format: date-time` by `DefaultValidator`. The JSR-310 adapters are therefore effectively write-only in this codebase. That is the intended behaviour.
94+
95+
Write caveat — documented in README:
96+
97+
- `GsonJsonMapper.writeTo(value)` calls `gson.toJson(value)` and returns UTF-8 bytes. With the integer-preserving and JSR-310 adapters above, this handles `Map<String,Object>`, `List<?>`, `String`, `Number`, `Boolean`, `null`, and the listed JSR-310 types correctly. For non-ISO date formats, locale-specific serialization, custom naming strategies, or any custom Java type, register a user-supplied `TypeMapper` for `application/json`. The fallback is intended for the "I'm already using Gson and the defaults are fine" case.
98+
99+
### `Request`
100+
101+
`com.retailsvc.http.Request` becomes the per-request handle. Concrete final class (no interface — YAGNI; extract later if testability demands it).
102+
103+
```java
104+
public final class Request {
105+
// read API — same data RequestContext exposes today
106+
public byte[] bytes();
107+
public Object parsed();
108+
public String operationId();
109+
public Map<String, String> pathParams();
110+
111+
// small conveniences
112+
public String header(String name);
113+
public Map<String, String> queryParams(); // parsed lazily, cached
114+
115+
// response gateway
116+
public ResponseBuilder respond(int status);
117+
}
118+
```
119+
120+
`ResponseBuilder` (fluent; exactly one terminal call per `Request`):
121+
122+
```java
123+
public interface ResponseBuilder {
124+
ResponseBuilder header(String name, String value);
125+
ResponseBuilder contentType(String contentType); // shorthand
126+
127+
// one-shot terminals
128+
void empty(); // sendResponseHeaders(status, -1)
129+
void bytes(byte[] body); // sendResponseHeaders(status, body.length)
130+
void text(String body); // utf-8; sets Content-Type if unset
131+
void json(Object body); // shorthand for body("application/json", body)
132+
void body(String mediaType, Object body); // looks up the registered TypeMapper
133+
void problem(ProblemDetail pd); // application/problem+json
134+
135+
// streaming terminals
136+
OutputStream stream(); // chunked; sendResponseHeaders(status, 0)
137+
OutputStream stream(long length); // known length
138+
}
139+
```
140+
141+
`body(mediaType, value)` looks up the `TypeMapper` registered for `mediaType`, calls `writeTo(value)`, sets `Content-Type` if not already set, and writes the bytes with `sendResponseHeaders(status, bytes.length)`. Unknown media type → `IllegalStateException`.
142+
143+
`.json(body)` is exactly `body("application/json", body)`. Kept because JSON is dominant and the call site reads better.
144+
145+
State machine, enforced via `IllegalStateException`:
146+
147+
- exactly one terminal call per `Request`;
148+
- `header(...)` / `contentType(...)` only before the terminal call;
149+
- streaming terminals return an `OutputStream` the handler is responsible for closing (the framework also closes it as a safety net when the exchange ends).
150+
151+
Empty bodies use `responseLength = -1` per the existing project convention (0 triggers chunked encoding).
152+
153+
### `RequestHandler`
154+
155+
```java
156+
@FunctionalInterface
157+
public interface RequestHandler {
158+
void handle(Request request) throws IOException;
159+
}
160+
```
161+
162+
`IOException` is kept on the signature for response-writing I/O. Unchecked exceptions continue to flow into the existing `ExceptionFilter``ExceptionHandler` path unchanged.
163+
164+
### Builder shape
165+
166+
```java
167+
OpenApiServer.builder()
168+
.spec(spec)
169+
.bodyMapper("application/json", jsonMapper) // required
170+
.bodyMapper("application/xml", xmlMapper) // optional extra
171+
.handlers(Map<String, RequestHandler> handlers) // type changed (breaking)
172+
.addHandler(String path, HttpHandler extra) // unchanged — raw HttpHandler
173+
.exceptionHandler(...)
174+
.port(...)
175+
.shutdownTimeoutSeconds(...)
176+
.build();
177+
```
178+
179+
`addHandler(path, HttpHandler)` for extras stays raw — extras are arbitrary side paths (health, metrics) that don't go through OpenAPI dispatch and don't benefit from `Request`.
180+
181+
The builder fails fast at `build()` time if no `TypeMapper` is registered for `application/json`.
182+
183+
### Filter → dispatcher handoff
184+
185+
`RequestPreparationFilter` reads the body, runs validation, and builds the `Request` object (including the parsed body, path params, operation ID, the resolved set of `TypeMapper`s, and a reference to the `HttpExchange`). It hands the `Request` to `DispatchHandler` via an internal, package-private `ScopedValue<Request>`.
186+
187+
The user-visible `Request.CONTEXT` `ScopedValue` and the static `Request.bytes()` / `.parsed()` / `.operationId()` / `.pathParams()` accessors are removed. The internal `RequestContext` record is removed.
188+
189+
`DispatchHandler` becomes:
190+
191+
```java
192+
final class DispatchHandler implements HttpHandler {
193+
static final ScopedValue<Request> CURRENT = ScopedValue.newInstance();
194+
private final Map<String, RequestHandler> handlers;
195+
196+
@Override
197+
public void handle(HttpExchange exchange) throws IOException {
198+
Request request = CURRENT.get();
199+
RequestHandler h = handlers.get(request.operationId());
200+
if (h == null) {
201+
throw new MissingOperationHandlerException(request.operationId());
202+
}
203+
h.handle(request);
204+
}
205+
}
206+
```
207+
208+
## Breaking changes
209+
210+
This is a pre-1.0 library; breaking changes are acceptable.
211+
212+
- `JsonMapper` removed; replaced by `TypeMapper`. Builder method `jsonMapper(JsonMapper)` becomes `bodyMapper("application/json", TypeMapper)`. No deprecated adapter is kept — the cutover happens in a single PR.
213+
- Builder method `handlers(Map<String, HttpHandler>)` becomes `handlers(Map<String, RequestHandler>)`.
214+
- Static accessors `Request.bytes()` / `Request.parsed()` / `Request.operationId()` / `Request.pathParams()` / `Request.current()` and the `Request.CONTEXT` `ScopedValue` are removed. Handlers read this data from the `Request` parameter.
215+
- The example launcher under `src/test/java/.../start/` is updated as part of this change.
216+
217+
## Testing
218+
219+
Existing integration tests (`*IT.java`) exercise the full stack and will be updated to use the new handler signature. Unit tests cover:
220+
221+
- `TypeMapper` registration: defaults wired, user overrides win, missing `application/json` mapper fails the builder when Gson is not on the classpath.
222+
- Gson fallback: with Gson on the classpath and no user JSON mapper, `build()` succeeds and `application/json` round-trips via `GsonJsonMapper`. Integer-preserving `TypeAdapter` returns `Long` for integral numbers and `Double` for fractional / out-of-range numbers. With Gson absent and no user JSON mapper, `build()` fails with the existing error.
223+
- JSR-310 write adapters: serializing `Map.of("ts", Instant.now())`, an `OffsetDateTime`, a `LocalDate`, etc. emits the ISO-8601 string form. One assertion per type.
224+
- Built-in text mapper: round-trip via `readFrom` and `writeTo`; charset handling.
225+
- Built-in form mapper: `readFrom` parses; `writeTo` throws `UnsupportedOperationException`.
226+
- `Request` read API: byte / parsed / operationId / pathParams round-trip.
227+
- `Request` response gateway: each terminal produces the right `sendResponseHeaders` length and `Content-Type`; double-terminal throws `IllegalStateException`; `header(...)` after terminal throws; `body(unknownMediaType, ...)` throws.
228+
- Streaming terminals: `stream()` uses chunked encoding (length 0); `stream(length)` uses the supplied length.
229+
- Form-coercion moved out of `FormUrlEncodedParser` — existing form-body validation tests must still pass.
230+
231+
## Migration order
232+
233+
The implementation plan will sequence this as:
234+
235+
1. Introduce `TypeMapper`; convert form and text built-ins to implement it; add the internal `GsonJsonMapper` (Gson as optional Maven dependency) and the builder's classpath-probe fallback; delete `JsonMapper`; switch the builder to `bodyMapper(String, TypeMapper)`; rewire `RequestPreparationFilter` to use the registered mappers and drop the hardcoded media-type switch.
236+
2. Move form-coercion out of `FormUrlEncodedParser` into the validator path.
237+
3. Build the new `Request` class (read API + response gateway), the internal `ScopedValue<Request>` handoff, and the `RequestHandler` interface; switch `handlers(...)` to `Map<String, RequestHandler>`; update example launcher and tests; delete the static `Request` accessors, the public `ScopedValue`, and the `RequestContext` record.

pom.xml

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,18 @@
3838
</dependencyManagement>
3939

4040
<dependencies>
41+
<dependency>
42+
<groupId>com.fasterxml.jackson.core</groupId>
43+
<artifactId>jackson-databind</artifactId>
44+
<version>2.21.3</version>
45+
<optional>true</optional>
46+
</dependency>
47+
<dependency>
48+
<groupId>com.google.code.gson</groupId>
49+
<artifactId>gson</artifactId>
50+
<version>2.14.0</version>
51+
<optional>true</optional>
52+
</dependency>
4153
<dependency>
4254
<groupId>org.yaml</groupId>
4355
<artifactId>snakeyaml</artifactId>
@@ -57,12 +69,6 @@
5769
<version>1.5.32</version>
5870
<scope>test</scope>
5971
</dependency>
60-
<dependency>
61-
<groupId>com.google.code.gson</groupId>
62-
<artifactId>gson</artifactId>
63-
<version>2.14.0</version>
64-
<scope>test</scope>
65-
</dependency>
6672
<dependency>
6773
<groupId>org.assertj</groupId>
6874
<artifactId>assertj-core</artifactId>
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package com.retailsvc.http;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import java.io.IOException;
5+
import java.io.UncheckedIOException;
6+
import java.util.Objects;
7+
8+
/**
9+
* {@link TypeMapper} for {@code application/json} backed by Jackson. The caller supplies a
10+
* fully-configured {@link ObjectMapper}; this class never adds modules or changes settings — the
11+
* mapper you pass is the mapper you get.
12+
*
13+
* <p>Implements {@link TypedTypeMapper}, so handlers can ask for a typed view of the body via
14+
* {@link Request#parsed(Class)}.
15+
*
16+
* <p>Typical wiring:
17+
*
18+
* <pre>{@code
19+
* OpenApiServer.builder()
20+
* .spec(spec)
21+
* .bodyMapper("application/json", new JacksonJsonTypeMapper(myObjectMapper))
22+
* .handlers(handlers)
23+
* .build();
24+
* }</pre>
25+
*
26+
* <p>Jackson is an <em>optional</em> Maven dependency of this library; consumers that use Jackson
27+
* must declare {@code jackson-databind} themselves. Consumers that use Gson can rely on the
28+
* built-in {@code GsonJsonMapper} auto-fallback instead.
29+
*/
30+
public final class JacksonJsonTypeMapper implements TypedTypeMapper {
31+
32+
private final ObjectMapper mapper;
33+
34+
public JacksonJsonTypeMapper(ObjectMapper mapper) {
35+
this.mapper = Objects.requireNonNull(mapper, "mapper must not be null");
36+
}
37+
38+
@Override
39+
public Object readFrom(byte[] body, String contentTypeHeader) {
40+
return readAs(body, contentTypeHeader, Object.class);
41+
}
42+
43+
@Override
44+
public <T> T readAs(byte[] body, String contentTypeHeader, Class<T> type) {
45+
try {
46+
return mapper.readValue(body, type);
47+
} catch (IOException e) {
48+
throw new UncheckedIOException(e);
49+
}
50+
}
51+
52+
@Override
53+
public byte[] writeTo(Object value) {
54+
try {
55+
return mapper.writeValueAsBytes(value);
56+
} catch (IOException e) {
57+
throw new UncheckedIOException(e);
58+
}
59+
}
60+
}

src/main/java/com/retailsvc/http/JsonMapper.java

Lines changed: 0 additions & 6 deletions
This file was deleted.

0 commit comments

Comments
 (0)