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