From b1287ee93b09f9af34fcb459fd17d1ffe518f3b7 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 12:32:41 +0200 Subject: [PATCH 01/50] docs: BodyReader and RequestHandler design --- ...5-13-body-reader-request-handler-design.md | 250 ++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-13-body-reader-request-handler-design.md diff --git a/docs/superpowers/specs/2026-05-13-body-reader-request-handler-design.md b/docs/superpowers/specs/2026-05-13-body-reader-request-handler-design.md new file mode 100644 index 0000000..3b9932c --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-body-reader-request-handler-design.md @@ -0,0 +1,250 @@ +# BodyReader and RequestHandler + +**Date:** 2026-05-13 +**Status:** Approved, ready for plan + +## Motivation + +The library currently hardcodes body parsing inside `RequestPreparationFilter`: a +`switch` on media type dispatches to `FormUrlEncodedParser`, `TextPlainParser`, +or the user-supplied `JsonMapper`. Adding a new media type (XML, CBOR, etc.) +requires editing the filter. The `JsonMapper` name is also misleading once we +treat it as one mapper among several. + +Handlers today receive a raw JDK `HttpExchange` and pull request data via +static accessors on `Request` backed by a `ScopedValue`. The +ScopedValue exists only because `HttpHandler.handle(HttpExchange)` has nowhere +else to carry prepared data. That side channel is unnecessary if handlers +receive their own per-request object directly. + +This change introduces two interfaces — `BodyReader` for pluggable parsing and +`RequestHandler` for handlers that receive a `Request` instead of an +`HttpExchange` — and folds response writing into `Request` as a fluent +gateway with one-shot and streaming terminals. + +## Scope + +In scope: + +- `BodyReader` interface and per-media-type registration on the builder. +- Rename `JsonMapper` → the JSON `BodyReader`; default form and text readers + wired automatically. +- New `RequestHandler` interface; `handlers(...)` builder method changed to + `Map` (breaking). +- `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. +- Internal `RequestContext` record and public `Request.CONTEXT` `ScopedValue` + removed. +- A required `jsonWriter(Object → byte[])` on the builder, used by + `Request.respond(...).json(...)`. Generalising to `BodyWriter`s is a future + change. + +Out of scope: + +- **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. +- A general `BodyWriter` abstraction symmetric to `BodyReader`. Response + serialization for non-JSON content types is the handler's responsibility via + `respond(...).bytes(...)` / `.text(...)` / `.stream(...)`. + +## Design + +### `BodyReader` + +```java +package com.retailsvc.http; + +@FunctionalInterface +public interface BodyReader { + Object readFrom(byte[] body, String contentTypeHeader); +} +``` + +`contentTypeHeader` is the full raw `Content-Type` header — required so form +and text readers can resolve `charset` and other parameters. JSON readers +ignore it. + +`BodyReader` 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 reader becomes a plain `byte[]` → `Map` step. + +### Builder registration + +```java +OpenApiServer.builder() + .spec(spec) + .bodyReader("application/json", jsonReader) // required (no default) + .bodyReader("application/xml", xmlReader) // optional extra + .jsonWriter(jsonWriter) // required + .handlers(Map.of("op", request -> { ... })) + .build(); +``` + +Defaults wired by the builder unless overridden: + +- `application/x-www-form-urlencoded` → built-in form reader. +- `text/plain` → built-in text reader. +- `application/json` → **no default**; the user must supply one (mirrors the + current contract that `jsonMapper(...)` is required). + +Lookup: case-insensitive on the media-type subtype (existing +`ContentTypeHeader.mediaType` already lowercases). No wildcard matching +(`text/*`, `*/*`) — out of scope. + +### `Request` + +`com.retailsvc.http.Request` becomes the per-request handle. Concrete final +class (no interface — YAGNI; extract later if testability demands it). + +```java +public final class Request { + // read API — same data RequestContext exposes today + public byte[] bytes(); + public Object parsed(); + public String operationId(); + public Map pathParams(); + + // small conveniences + public String header(String name); + public Map queryParams(); // parsed lazily, cached + + // response gateway + public ResponseBuilder respond(int status); +} +``` + +`ResponseBuilder` (fluent; exactly one terminal call per `Request`): + +```java +public interface ResponseBuilder { + ResponseBuilder header(String name, String value); + ResponseBuilder contentType(String contentType); // shorthand + + // one-shot terminals + void empty(); // sendResponseHeaders(status, -1) + void bytes(byte[] body); // sendResponseHeaders(status, body.length) + void text(String body); // utf-8; sets Content-Type if unset + void json(Object body); // jsonWriter; sets Content-Type if unset + void problem(ProblemDetail pd); // application/problem+json + + // streaming terminals + OutputStream stream(); // chunked; sendResponseHeaders(status, 0) + OutputStream stream(long length); // known length +} +``` + +State machine, enforced via `IllegalStateException`: + +- exactly one terminal call per `Request`; +- `header(...)` / `contentType(...)` only before the terminal call; +- streaming terminals return an `OutputStream` the handler is responsible for + closing (the framework also closes it as a safety net when the exchange ends). + +Empty bodies use `responseLength = -1` per the existing project convention +(0 triggers chunked encoding). + +### `RequestHandler` + +```java +@FunctionalInterface +public interface RequestHandler { + void handle(Request request) throws IOException; +} +``` + +`IOException` is kept on the signature for response-writing I/O. Unchecked +exceptions continue to flow into the existing `ExceptionFilter` → +`ExceptionHandler` path unchanged. + +### Builder shape + +```java +OpenApiServer.builder() + .spec(spec) + .bodyReader(String mediaType, BodyReader reader) + .jsonWriter(Function writer) + .handlers(Map handlers) // type changed (breaking) + .addHandler(String path, HttpHandler extra) // unchanged — raw HttpHandler + .exceptionHandler(...) + .port(...) + .shutdownTimeoutSeconds(...) + .build(); +``` + +`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`. + +### Filter → dispatcher handoff + +`RequestPreparationFilter` reads the body, runs validation, and builds the +`Request` object (including the parsed body, path params, operation ID, and a +reference to the `HttpExchange`). It hands the `Request` to `DispatchHandler` +via an internal, package-private `ScopedValue`. + +The user-visible `Request.CONTEXT` `ScopedValue` and the static +`Request.bytes()` / `.parsed()` / `.operationId()` / `.pathParams()` accessors +are removed. The internal `RequestContext` record is removed. + +`DispatchHandler` becomes: + +```java +final class DispatchHandler implements HttpHandler { + static final ScopedValue CURRENT = ScopedValue.newInstance(); + private final Map handlers; + + @Override + public void handle(HttpExchange exchange) throws IOException { + Request request = CURRENT.get(); + RequestHandler h = handlers.get(request.operationId()); + if (h == null) { + throw new MissingOperationHandlerException(request.operationId()); + } + h.handle(request); + } +} +``` + +## Breaking changes + +This is a pre-1.0 library; breaking changes are acceptable. + +- `JsonMapper` removed; replaced by `BodyReader`. The builder method + `jsonMapper(JsonMapper)` becomes `bodyReader("application/json", BodyReader)`. +- Builder method `handlers(Map)` becomes + `handlers(Map)`. +- 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. +- The example launcher under `src/test/java/.../start/` is updated as part of + this change. + +## Testing + +Existing integration tests (`*IT.java`) exercise the full stack and will be +updated to use the new handler signature. Unit tests cover: + +- `BodyReader` registration: defaults wired, user overrides win, missing + `application/json` reader fails the builder. +- `Request` read API: byte/parsed/operationId/pathParams round-trip. +- `Request` response gateway: each terminal produces the right + `sendResponseHeaders` length and `Content-Type`; double-terminal throws + `IllegalStateException`; `header(...)` after terminal throws. +- Streaming terminals: `stream()` uses chunked encoding (length 0); + `stream(length)` uses the supplied length. +- Form-coercion moved out of `FormUrlEncodedParser` — existing form-body + validation tests must still pass. + +## Migration order + +The implementation plan will sequence this as: + +1. Introduce `BodyReader`; keep `JsonMapper` as a deprecated adapter. +2. Move form-coercion out of `FormUrlEncodedParser` into the validator path. +3. Build the new `Request` class (read API + response gateway) and the internal `ScopedValue` handoff; keep the old static `Request` accessors alive temporarily. +4. Introduce `RequestHandler`; update the builder; update example launcher and tests. +5. Remove `JsonMapper`, the static `Request` accessors, and the `RequestContext` record. From 5648af8ce8b2b06831c7fb4845da9131d62411a2 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 13:06:29 +0200 Subject: [PATCH 02/50] docs: Switch BodyReader/Writer to single TypeMapper interface --- ...5-13-body-reader-request-handler-design.md | 250 ------------------ ...5-13-type-mapper-request-handler-design.md | 206 +++++++++++++++ 2 files changed, 206 insertions(+), 250 deletions(-) delete mode 100644 docs/superpowers/specs/2026-05-13-body-reader-request-handler-design.md create mode 100644 docs/superpowers/specs/2026-05-13-type-mapper-request-handler-design.md diff --git a/docs/superpowers/specs/2026-05-13-body-reader-request-handler-design.md b/docs/superpowers/specs/2026-05-13-body-reader-request-handler-design.md deleted file mode 100644 index 3b9932c..0000000 --- a/docs/superpowers/specs/2026-05-13-body-reader-request-handler-design.md +++ /dev/null @@ -1,250 +0,0 @@ -# BodyReader and RequestHandler - -**Date:** 2026-05-13 -**Status:** Approved, ready for plan - -## Motivation - -The library currently hardcodes body parsing inside `RequestPreparationFilter`: a -`switch` on media type dispatches to `FormUrlEncodedParser`, `TextPlainParser`, -or the user-supplied `JsonMapper`. Adding a new media type (XML, CBOR, etc.) -requires editing the filter. The `JsonMapper` name is also misleading once we -treat it as one mapper among several. - -Handlers today receive a raw JDK `HttpExchange` and pull request data via -static accessors on `Request` backed by a `ScopedValue`. The -ScopedValue exists only because `HttpHandler.handle(HttpExchange)` has nowhere -else to carry prepared data. That side channel is unnecessary if handlers -receive their own per-request object directly. - -This change introduces two interfaces — `BodyReader` for pluggable parsing and -`RequestHandler` for handlers that receive a `Request` instead of an -`HttpExchange` — and folds response writing into `Request` as a fluent -gateway with one-shot and streaming terminals. - -## Scope - -In scope: - -- `BodyReader` interface and per-media-type registration on the builder. -- Rename `JsonMapper` → the JSON `BodyReader`; default form and text readers - wired automatically. -- New `RequestHandler` interface; `handlers(...)` builder method changed to - `Map` (breaking). -- `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. -- Internal `RequestContext` record and public `Request.CONTEXT` `ScopedValue` - removed. -- A required `jsonWriter(Object → byte[])` on the builder, used by - `Request.respond(...).json(...)`. Generalising to `BodyWriter`s is a future - change. - -Out of scope: - -- **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. -- A general `BodyWriter` abstraction symmetric to `BodyReader`. Response - serialization for non-JSON content types is the handler's responsibility via - `respond(...).bytes(...)` / `.text(...)` / `.stream(...)`. - -## Design - -### `BodyReader` - -```java -package com.retailsvc.http; - -@FunctionalInterface -public interface BodyReader { - Object readFrom(byte[] body, String contentTypeHeader); -} -``` - -`contentTypeHeader` is the full raw `Content-Type` header — required so form -and text readers can resolve `charset` and other parameters. JSON readers -ignore it. - -`BodyReader` 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 reader becomes a plain `byte[]` → `Map` step. - -### Builder registration - -```java -OpenApiServer.builder() - .spec(spec) - .bodyReader("application/json", jsonReader) // required (no default) - .bodyReader("application/xml", xmlReader) // optional extra - .jsonWriter(jsonWriter) // required - .handlers(Map.of("op", request -> { ... })) - .build(); -``` - -Defaults wired by the builder unless overridden: - -- `application/x-www-form-urlencoded` → built-in form reader. -- `text/plain` → built-in text reader. -- `application/json` → **no default**; the user must supply one (mirrors the - current contract that `jsonMapper(...)` is required). - -Lookup: case-insensitive on the media-type subtype (existing -`ContentTypeHeader.mediaType` already lowercases). No wildcard matching -(`text/*`, `*/*`) — out of scope. - -### `Request` - -`com.retailsvc.http.Request` becomes the per-request handle. Concrete final -class (no interface — YAGNI; extract later if testability demands it). - -```java -public final class Request { - // read API — same data RequestContext exposes today - public byte[] bytes(); - public Object parsed(); - public String operationId(); - public Map pathParams(); - - // small conveniences - public String header(String name); - public Map queryParams(); // parsed lazily, cached - - // response gateway - public ResponseBuilder respond(int status); -} -``` - -`ResponseBuilder` (fluent; exactly one terminal call per `Request`): - -```java -public interface ResponseBuilder { - ResponseBuilder header(String name, String value); - ResponseBuilder contentType(String contentType); // shorthand - - // one-shot terminals - void empty(); // sendResponseHeaders(status, -1) - void bytes(byte[] body); // sendResponseHeaders(status, body.length) - void text(String body); // utf-8; sets Content-Type if unset - void json(Object body); // jsonWriter; sets Content-Type if unset - void problem(ProblemDetail pd); // application/problem+json - - // streaming terminals - OutputStream stream(); // chunked; sendResponseHeaders(status, 0) - OutputStream stream(long length); // known length -} -``` - -State machine, enforced via `IllegalStateException`: - -- exactly one terminal call per `Request`; -- `header(...)` / `contentType(...)` only before the terminal call; -- streaming terminals return an `OutputStream` the handler is responsible for - closing (the framework also closes it as a safety net when the exchange ends). - -Empty bodies use `responseLength = -1` per the existing project convention -(0 triggers chunked encoding). - -### `RequestHandler` - -```java -@FunctionalInterface -public interface RequestHandler { - void handle(Request request) throws IOException; -} -``` - -`IOException` is kept on the signature for response-writing I/O. Unchecked -exceptions continue to flow into the existing `ExceptionFilter` → -`ExceptionHandler` path unchanged. - -### Builder shape - -```java -OpenApiServer.builder() - .spec(spec) - .bodyReader(String mediaType, BodyReader reader) - .jsonWriter(Function writer) - .handlers(Map handlers) // type changed (breaking) - .addHandler(String path, HttpHandler extra) // unchanged — raw HttpHandler - .exceptionHandler(...) - .port(...) - .shutdownTimeoutSeconds(...) - .build(); -``` - -`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`. - -### Filter → dispatcher handoff - -`RequestPreparationFilter` reads the body, runs validation, and builds the -`Request` object (including the parsed body, path params, operation ID, and a -reference to the `HttpExchange`). It hands the `Request` to `DispatchHandler` -via an internal, package-private `ScopedValue`. - -The user-visible `Request.CONTEXT` `ScopedValue` and the static -`Request.bytes()` / `.parsed()` / `.operationId()` / `.pathParams()` accessors -are removed. The internal `RequestContext` record is removed. - -`DispatchHandler` becomes: - -```java -final class DispatchHandler implements HttpHandler { - static final ScopedValue CURRENT = ScopedValue.newInstance(); - private final Map handlers; - - @Override - public void handle(HttpExchange exchange) throws IOException { - Request request = CURRENT.get(); - RequestHandler h = handlers.get(request.operationId()); - if (h == null) { - throw new MissingOperationHandlerException(request.operationId()); - } - h.handle(request); - } -} -``` - -## Breaking changes - -This is a pre-1.0 library; breaking changes are acceptable. - -- `JsonMapper` removed; replaced by `BodyReader`. The builder method - `jsonMapper(JsonMapper)` becomes `bodyReader("application/json", BodyReader)`. -- Builder method `handlers(Map)` becomes - `handlers(Map)`. -- 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. -- The example launcher under `src/test/java/.../start/` is updated as part of - this change. - -## Testing - -Existing integration tests (`*IT.java`) exercise the full stack and will be -updated to use the new handler signature. Unit tests cover: - -- `BodyReader` registration: defaults wired, user overrides win, missing - `application/json` reader fails the builder. -- `Request` read API: byte/parsed/operationId/pathParams round-trip. -- `Request` response gateway: each terminal produces the right - `sendResponseHeaders` length and `Content-Type`; double-terminal throws - `IllegalStateException`; `header(...)` after terminal throws. -- Streaming terminals: `stream()` uses chunked encoding (length 0); - `stream(length)` uses the supplied length. -- Form-coercion moved out of `FormUrlEncodedParser` — existing form-body - validation tests must still pass. - -## Migration order - -The implementation plan will sequence this as: - -1. Introduce `BodyReader`; keep `JsonMapper` as a deprecated adapter. -2. Move form-coercion out of `FormUrlEncodedParser` into the validator path. -3. Build the new `Request` class (read API + response gateway) and the internal `ScopedValue` handoff; keep the old static `Request` accessors alive temporarily. -4. Introduce `RequestHandler`; update the builder; update example launcher and tests. -5. Remove `JsonMapper`, the static `Request` accessors, and the `RequestContext` record. diff --git a/docs/superpowers/specs/2026-05-13-type-mapper-request-handler-design.md b/docs/superpowers/specs/2026-05-13-type-mapper-request-handler-design.md new file mode 100644 index 0000000..c2bdf7b --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-type-mapper-request-handler-design.md @@ -0,0 +1,206 @@ +# TypeMapper and RequestHandler + +**Date:** 2026-05-13 +**Status:** Approved, ready for plan + +## Motivation + +The library currently hardcodes body parsing inside `RequestPreparationFilter`: a +`switch` on media type dispatches to `FormUrlEncodedParser`, `TextPlainParser`, +or the user-supplied `JsonMapper`. Adding a new media type (XML, CBOR, etc.) +requires editing the filter. The `JsonMapper` name is also misleading once we +treat it as one mapper among several, and the response side has no symmetric +abstraction at all — handlers have to write bytes manually. + +Handlers today receive a raw JDK `HttpExchange` and pull request data via +static accessors on `Request` backed by a `ScopedValue`. The +ScopedValue exists only because `HttpHandler.handle(HttpExchange)` has nowhere +else to carry prepared data. That side channel is unnecessary if handlers +receive their own per-request object directly. + +This change introduces two interfaces — `TypeMapper` for pluggable +read/write per media type, and `RequestHandler` for handlers that receive a +`Request` instead of an `HttpExchange` — and folds response writing into +`Request` as a fluent gateway with one-shot and streaming terminals. + +## Scope + +In scope: + +- `TypeMapper` interface (read + write) and per-media-type registration on the builder. +- Rename `JsonMapper` → user-supplied `TypeMapper` for `application/json`; default form and text mappers wired automatically. +- New `RequestHandler` interface; `handlers(...)` builder method changed to `Map` (breaking). +- `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. +- Internal `RequestContext` record and public `Request.CONTEXT` `ScopedValue` removed. + +Out of scope: + +- **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. +- Wildcard media-type matching (`text/*`, `*/*`). + +## Design + +### `TypeMapper` + +```java +package com.retailsvc.http; + +public interface TypeMapper { + Object readFrom(byte[] body, String contentTypeHeader); + byte[] writeTo(Object value); +} +``` + +`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. + +`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` step on the read side. + +### Built-in defaults + +`TypeMapper` applies uniformly to every media type, including the built-ins. Defaults wired by the builder unless overridden: + +- `application/x-www-form-urlencoded` — built-in form mapper. `readFrom` parses to `Map`. `writeTo` throws `UnsupportedOperationException`; form-encoded responses are unusual and we won't speculate on the encoding until someone needs it. +- `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)`. +- `application/json` — **no default**; the user must supply a `TypeMapper`. Mirrors the current contract that `jsonMapper(...)` is required. + +Lookup: case-insensitive on the media-type subtype (existing `ContentTypeHeader.mediaType` already lowercases). + +### `Request` + +`com.retailsvc.http.Request` becomes the per-request handle. Concrete final class (no interface — YAGNI; extract later if testability demands it). + +```java +public final class Request { + // read API — same data RequestContext exposes today + public byte[] bytes(); + public Object parsed(); + public String operationId(); + public Map pathParams(); + + // small conveniences + public String header(String name); + public Map queryParams(); // parsed lazily, cached + + // response gateway + public ResponseBuilder respond(int status); +} +``` + +`ResponseBuilder` (fluent; exactly one terminal call per `Request`): + +```java +public interface ResponseBuilder { + ResponseBuilder header(String name, String value); + ResponseBuilder contentType(String contentType); // shorthand + + // one-shot terminals + void empty(); // sendResponseHeaders(status, -1) + void bytes(byte[] body); // sendResponseHeaders(status, body.length) + void text(String body); // utf-8; sets Content-Type if unset + void json(Object body); // shorthand for body("application/json", body) + void body(String mediaType, Object body); // looks up the registered TypeMapper + void problem(ProblemDetail pd); // application/problem+json + + // streaming terminals + OutputStream stream(); // chunked; sendResponseHeaders(status, 0) + OutputStream stream(long length); // known length +} +``` + +`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`. + +`.json(body)` is exactly `body("application/json", body)`. Kept because JSON is dominant and the call site reads better. + +State machine, enforced via `IllegalStateException`: + +- exactly one terminal call per `Request`; +- `header(...)` / `contentType(...)` only before the terminal call; +- streaming terminals return an `OutputStream` the handler is responsible for closing (the framework also closes it as a safety net when the exchange ends). + +Empty bodies use `responseLength = -1` per the existing project convention (0 triggers chunked encoding). + +### `RequestHandler` + +```java +@FunctionalInterface +public interface RequestHandler { + void handle(Request request) throws IOException; +} +``` + +`IOException` is kept on the signature for response-writing I/O. Unchecked exceptions continue to flow into the existing `ExceptionFilter` → `ExceptionHandler` path unchanged. + +### Builder shape + +```java +OpenApiServer.builder() + .spec(spec) + .bodyMapper("application/json", jsonMapper) // required + .bodyMapper("application/xml", xmlMapper) // optional extra + .handlers(Map handlers) // type changed (breaking) + .addHandler(String path, HttpHandler extra) // unchanged — raw HttpHandler + .exceptionHandler(...) + .port(...) + .shutdownTimeoutSeconds(...) + .build(); +``` + +`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`. + +The builder fails fast at `build()` time if no `TypeMapper` is registered for `application/json`. + +### Filter → dispatcher handoff + +`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`. + +The user-visible `Request.CONTEXT` `ScopedValue` and the static `Request.bytes()` / `.parsed()` / `.operationId()` / `.pathParams()` accessors are removed. The internal `RequestContext` record is removed. + +`DispatchHandler` becomes: + +```java +final class DispatchHandler implements HttpHandler { + static final ScopedValue CURRENT = ScopedValue.newInstance(); + private final Map handlers; + + @Override + public void handle(HttpExchange exchange) throws IOException { + Request request = CURRENT.get(); + RequestHandler h = handlers.get(request.operationId()); + if (h == null) { + throw new MissingOperationHandlerException(request.operationId()); + } + h.handle(request); + } +} +``` + +## Breaking changes + +This is a pre-1.0 library; breaking changes are acceptable. + +- `JsonMapper` removed; replaced by `TypeMapper`. Builder method `jsonMapper(JsonMapper)` becomes `bodyMapper("application/json", TypeMapper)`. +- Builder method `handlers(Map)` becomes `handlers(Map)`. +- 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. +- The example launcher under `src/test/java/.../start/` is updated as part of this change. + +## Testing + +Existing integration tests (`*IT.java`) exercise the full stack and will be updated to use the new handler signature. Unit tests cover: + +- `TypeMapper` registration: defaults wired, user overrides win, missing `application/json` mapper fails the builder. +- Built-in text mapper: round-trip via `readFrom` and `writeTo`; charset handling. +- Built-in form mapper: `readFrom` parses; `writeTo` throws `UnsupportedOperationException`. +- `Request` read API: byte / parsed / operationId / pathParams round-trip. +- `Request` response gateway: each terminal produces the right `sendResponseHeaders` length and `Content-Type`; double-terminal throws `IllegalStateException`; `header(...)` after terminal throws; `body(unknownMediaType, ...)` throws. +- Streaming terminals: `stream()` uses chunked encoding (length 0); `stream(length)` uses the supplied length. +- Form-coercion moved out of `FormUrlEncodedParser` — existing form-body validation tests must still pass. + +## Migration order + +The implementation plan will sequence this as: + +1. Introduce `TypeMapper`; convert form and text built-ins to implement it; convert `JsonMapper` to a deprecated adapter that wraps a `TypeMapper`. +2. Move form-coercion out of `FormUrlEncodedParser` into the validator path. +3. Build the new `Request` class (read API + response gateway) and the internal `ScopedValue` handoff; keep the old static `Request` accessors alive temporarily. +4. Introduce `RequestHandler`; update the builder (`bodyMapper(...)`, `handlers(Map)`); update example launcher and tests. +5. Remove `JsonMapper`, the static `Request` accessors, and the `RequestContext` record. From 1c36d753472f8c43504e0f361ce6cca5b35725bc Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 13:08:25 +0200 Subject: [PATCH 03/50] docs: Drop deprecated JsonMapper adapter from migration plan --- .../2026-05-13-type-mapper-request-handler-design.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-05-13-type-mapper-request-handler-design.md b/docs/superpowers/specs/2026-05-13-type-mapper-request-handler-design.md index c2bdf7b..46751c4 100644 --- a/docs/superpowers/specs/2026-05-13-type-mapper-request-handler-design.md +++ b/docs/superpowers/specs/2026-05-13-type-mapper-request-handler-design.md @@ -28,7 +28,7 @@ read/write per media type, and `RequestHandler` for handlers that receive a In scope: - `TypeMapper` interface (read + write) and per-media-type registration on the builder. -- Rename `JsonMapper` → user-supplied `TypeMapper` for `application/json`; default form and text mappers wired automatically. +- Delete `JsonMapper`; the user supplies a `TypeMapper` for `application/json` instead. Default form and text mappers wired automatically. - New `RequestHandler` interface; `handlers(...)` builder method changed to `Map` (breaking). - `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. - Internal `RequestContext` record and public `Request.CONTEXT` `ScopedValue` removed. @@ -178,7 +178,7 @@ final class DispatchHandler implements HttpHandler { This is a pre-1.0 library; breaking changes are acceptable. -- `JsonMapper` removed; replaced by `TypeMapper`. Builder method `jsonMapper(JsonMapper)` becomes `bodyMapper("application/json", TypeMapper)`. +- `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. - Builder method `handlers(Map)` becomes `handlers(Map)`. - 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. - The example launcher under `src/test/java/.../start/` is updated as part of this change. @@ -199,8 +199,6 @@ Existing integration tests (`*IT.java`) exercise the full stack and will be upda The implementation plan will sequence this as: -1. Introduce `TypeMapper`; convert form and text built-ins to implement it; convert `JsonMapper` to a deprecated adapter that wraps a `TypeMapper`. +1. Introduce `TypeMapper`; convert form and text built-ins to implement it; delete `JsonMapper`; switch the builder to `bodyMapper(String, TypeMapper)`; rewire `RequestPreparationFilter` to use the registered mappers and drop the hardcoded media-type switch. 2. Move form-coercion out of `FormUrlEncodedParser` into the validator path. -3. Build the new `Request` class (read API + response gateway) and the internal `ScopedValue` handoff; keep the old static `Request` accessors alive temporarily. -4. Introduce `RequestHandler`; update the builder (`bodyMapper(...)`, `handlers(Map)`); update example launcher and tests. -5. Remove `JsonMapper`, the static `Request` accessors, and the `RequestContext` record. +3. Build the new `Request` class (read API + response gateway), the internal `ScopedValue` handoff, and the `RequestHandler` interface; switch `handlers(...)` to `Map`; update example launcher and tests; delete the static `Request` accessors, the public `ScopedValue`, and the `RequestContext` record. From 27a4b4039dd6a01f7d52de4e5e850eb7e700bc12 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 13:16:03 +0200 Subject: [PATCH 04/50] docs: Add optional Gson fallback for application/json mapper --- ...5-13-type-mapper-request-handler-design.md | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/specs/2026-05-13-type-mapper-request-handler-design.md b/docs/superpowers/specs/2026-05-13-type-mapper-request-handler-design.md index 46751c4..4ff14dc 100644 --- a/docs/superpowers/specs/2026-05-13-type-mapper-request-handler-design.md +++ b/docs/superpowers/specs/2026-05-13-type-mapper-request-handler-design.md @@ -28,7 +28,7 @@ read/write per media type, and `RequestHandler` for handlers that receive a In scope: - `TypeMapper` interface (read + write) and per-media-type registration on the builder. -- Delete `JsonMapper`; the user supplies a `TypeMapper` for `application/json` instead. Default form and text mappers wired automatically. +- 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. - New `RequestHandler` interface; `handlers(...)` builder method changed to `Map` (breaking). - `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. - Internal `RequestContext` record and public `Request.CONTEXT` `ScopedValue` removed. @@ -61,10 +61,35 @@ public interface TypeMapper { - `application/x-www-form-urlencoded` — built-in form mapper. `readFrom` parses to `Map`. `writeTo` throws `UnsupportedOperationException`; form-encoded responses are unusual and we won't speculate on the encoding until someone needs it. - `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)`. -- `application/json` — **no default**; the user must supply a `TypeMapper`. Mirrors the current contract that `jsonMapper(...)` is required. +- `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. Lookup: case-insensitive on the media-type subtype (existing `ContentTypeHeader.mediaType` already lowercases). +### Optional Gson fallback for `application/json` + +To shrink setup for callers that already use Gson, the library ships an internal Gson-backed `TypeMapper` and auto-registers it when: + +1. The builder reaches `build()` and no `TypeMapper` has been registered for `application/json`; and +2. `com.google.gson.Gson` is resolvable on the classpath. + +Implementation: + +- Gson is an **optional** Maven dependency (`true` / `provided`). The library does not pull Gson into consumer classpaths. +- 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`. +- 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). + +Number handling on read: + +- 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` that: + - reads integral JSON numbers (no fraction, no exponent producing a fraction) into `Long`; + - reads non-integral or out-of-`Long`-range numbers into `Double`; + - reads everything else (`String`, `Boolean`, `null`, arrays, objects) the way Gson's default does. +- This is a well-known Gson pattern; ~30 lines, tested in isolation. + +Number handling on write — known caveat, documented in README: + +- `GsonJsonMapper.writeTo(value)` calls `gson.toJson(value)` on a default `Gson` instance and returns UTF-8 bytes. This handles `Map`, `List`, `String`, `Number`, `Boolean`, and `null` cleanly. It does **not** serialise `java.time.Instant`, `LocalDate`, or other JSR-310 types in a useful way (Gson's default emits internal field values). Callers that return such types from handlers — or want any non-default serialization — must register their own `TypeMapper`. The fallback is intended for the "I'm already using Gson and the defaults are fine" case. + ### `Request` `com.retailsvc.http.Request` becomes the per-request handle. Concrete final class (no interface — YAGNI; extract later if testability demands it). @@ -187,7 +212,8 @@ This is a pre-1.0 library; breaking changes are acceptable. Existing integration tests (`*IT.java`) exercise the full stack and will be updated to use the new handler signature. Unit tests cover: -- `TypeMapper` registration: defaults wired, user overrides win, missing `application/json` mapper fails the builder. +- `TypeMapper` registration: defaults wired, user overrides win, missing `application/json` mapper fails the builder when Gson is not on the classpath. +- 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. - Built-in text mapper: round-trip via `readFrom` and `writeTo`; charset handling. - Built-in form mapper: `readFrom` parses; `writeTo` throws `UnsupportedOperationException`. - `Request` read API: byte / parsed / operationId / pathParams round-trip. @@ -199,6 +225,6 @@ Existing integration tests (`*IT.java`) exercise the full stack and will be upda The implementation plan will sequence this as: -1. Introduce `TypeMapper`; convert form and text built-ins to implement it; delete `JsonMapper`; switch the builder to `bodyMapper(String, TypeMapper)`; rewire `RequestPreparationFilter` to use the registered mappers and drop the hardcoded media-type switch. +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. 2. Move form-coercion out of `FormUrlEncodedParser` into the validator path. 3. Build the new `Request` class (read API + response gateway), the internal `ScopedValue` handoff, and the `RequestHandler` interface; switch `handlers(...)` to `Map`; update example launcher and tests; delete the static `Request` accessors, the public `ScopedValue`, and the `RequestContext` record. From e3cee8fb4b58ba142a0a1aa0a1760bd4efd60a53 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 13:18:05 +0200 Subject: [PATCH 05/50] docs: Add JSR-310 write adapters to default Gson mapper --- .../2026-05-13-type-mapper-request-handler-design.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-05-13-type-mapper-request-handler-design.md b/docs/superpowers/specs/2026-05-13-type-mapper-request-handler-design.md index 4ff14dc..f612adc 100644 --- a/docs/superpowers/specs/2026-05-13-type-mapper-request-handler-design.md +++ b/docs/superpowers/specs/2026-05-13-type-mapper-request-handler-design.md @@ -86,9 +86,15 @@ Number handling on read: - reads everything else (`String`, `Boolean`, `null`, arrays, objects) the way Gson's default does. - This is a well-known Gson pattern; ~30 lines, tested in isolation. -Number handling on write — known caveat, documented in README: +JSR-310 handling on write: -- `GsonJsonMapper.writeTo(value)` calls `gson.toJson(value)` on a default `Gson` instance and returns UTF-8 bytes. This handles `Map`, `List`, `String`, `Number`, `Boolean`, and `null` cleanly. It does **not** serialise `java.time.Instant`, `LocalDate`, or other JSR-310 types in a useful way (Gson's default emits internal field values). Callers that return such types from handlers — or want any non-default serialization — must register their own `TypeMapper`. The fallback is intended for the "I'm already using Gson and the defaults are fine" case. +- 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. +- Without these adapters Gson's default would serialise these types using internal field values, which is never what handlers want. +- Read direction is unaffected: the library parses bodies into raw `Object` (`Map` / `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. + +Write caveat — documented in README: + +- `GsonJsonMapper.writeTo(value)` calls `gson.toJson(value)` and returns UTF-8 bytes. With the integer-preserving and JSR-310 adapters above, this handles `Map`, `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. ### `Request` @@ -214,6 +220,7 @@ Existing integration tests (`*IT.java`) exercise the full stack and will be upda - `TypeMapper` registration: defaults wired, user overrides win, missing `application/json` mapper fails the builder when Gson is not on the classpath. - 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. +- 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. - Built-in text mapper: round-trip via `readFrom` and `writeTo`; charset handling. - Built-in form mapper: `readFrom` parses; `writeTo` throws `UnsupportedOperationException`. - `Request` read API: byte / parsed / operationId / pathParams round-trip. From cddb354d6204a97bb8b92130e599a04f12ec8a41 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 13:26:54 +0200 Subject: [PATCH 06/50] docs: Implementation plan for TypeMapper and RequestHandler --- .../2026-05-13-type-mapper-request-handler.md | 1858 +++++++++++++++++ 1 file changed, 1858 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-13-type-mapper-request-handler.md diff --git a/docs/superpowers/plans/2026-05-13-type-mapper-request-handler.md b/docs/superpowers/plans/2026-05-13-type-mapper-request-handler.md new file mode 100644 index 0000000..8645d6f --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-type-mapper-request-handler.md @@ -0,0 +1,1858 @@ +# TypeMapper and RequestHandler Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace `JsonMapper` with a per-media-type `TypeMapper` (read + write) registered on the builder, with an optional auto-fallback to a Gson-backed default for `application/json`. Replace handler API `Map` (which receives `HttpExchange`) with `Map` receiving a `Request` per-request handle that owns both the read API and a fluent response gateway (one-shot + streaming). + +**Architecture:** Spec at `docs/superpowers/specs/2026-05-13-type-mapper-request-handler-design.md`. Pre-1.0; breaking API changes accepted; single PR cutover. TDD throughout, frequent commits. Each task ends with `mvn test` (or `mvn verify` for IT) green. + +**Tech Stack:** Java 25, `com.sun.net.httpserver.HttpServer`, JUnit 5, AssertJ, Gson (becomes optional Maven dependency). + +--- + +## File Structure + +**Will be created:** + +- `src/main/java/com/retailsvc/http/TypeMapper.java` — new public interface. +- `src/main/java/com/retailsvc/http/RequestHandler.java` — new public functional interface. +- `src/main/java/com/retailsvc/http/ResponseBuilder.java` — new public interface returned by `Request.respond(int)`. +- `src/main/java/com/retailsvc/http/internal/FormTypeMapper.java` — built-in `application/x-www-form-urlencoded` `TypeMapper`. +- `src/main/java/com/retailsvc/http/internal/TextTypeMapper.java` — built-in `text/plain` `TypeMapper`. +- `src/main/java/com/retailsvc/http/internal/FormBodyCoercion.java` — schema-aware coercion extracted from `FormUrlEncodedParser`. +- `src/main/java/com/retailsvc/http/internal/DefaultResponseBuilder.java` — concrete `ResponseBuilder` wrapping `HttpExchange`. +- `src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java` — built-in Gson-backed `TypeMapper` for `application/json`. +- `src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java` +- `src/test/java/com/retailsvc/http/RequestResponseGatewayTest.java` +- `src/test/java/com/retailsvc/http/internal/FormTypeMapperTest.java` +- `src/test/java/com/retailsvc/http/internal/TextTypeMapperTest.java` +- `src/test/java/com/retailsvc/http/internal/gson/GsonJsonMapperTest.java` + +**Will be modified:** + +- `pom.xml` — move Gson from test scope to compile scope with `true`. +- `src/main/java/com/retailsvc/http/Request.java` — rewritten from static-accessor utility to per-request handle. +- `src/main/java/com/retailsvc/http/OpenApiServer.java` — constructors and `Builder` updated (`jsonMapper(...)` removed, `bodyMapper(...)` added, `handlers(...)` takes `Map`). +- `src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java` — dispatch via `Map`; construct `Request`; bind to internal `ScopedValue`. +- `src/main/java/com/retailsvc/http/internal/DispatchHandler.java` — reads `Request` from new `ScopedValue`, calls `RequestHandler`. +- `src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java` — drop `parseAndCoerce`; keep `parse` only. +- `src/test/java/com/retailsvc/http/ServerBaseTest.java` — switch to `bodyMapper(...)` + `RequestHandler`. +- `src/test/java/com/retailsvc/http/start/ServerLauncher.java`, `start/PostDataHandler.java`, etc. — migrate to new APIs. +- `src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java`, `OpenApiServerTest.java`, `ExtraHandlersIT.java`, `internal/RequestPreparationFilterTest.java` — adjust to new APIs. +- `README.md` — document `bodyMapper(...)`, Gson fallback + write caveat, new `RequestHandler` shape. + +**Will be deleted:** + +- `src/main/java/com/retailsvc/http/JsonMapper.java` +- `src/main/java/com/retailsvc/http/internal/RequestContext.java` +- `src/test/java/com/retailsvc/http/JsonMapperTest.java` + +--- + +## Task 1: Extract form-body schema coercion out of `FormUrlEncodedParser` + +`FormUrlEncodedParser.parseAndCoerce(byte[], String, Schema)` mixes parsing with schema-aware coercion. To make the form parser fit `TypeMapper` (which has no `Schema` parameter), move coercion into a small internal helper and call it from `RequestPreparationFilter` for the form media type. + +**Files:** +- Create: `src/main/java/com/retailsvc/http/internal/FormBodyCoercion.java` +- Modify: `src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java` (remove `parseAndCoerce`) +- Modify: `src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java` (line 155-156: invoke coercion after parsing) + +- [ ] **Step 1: Run the existing form-body tests so we know the current baseline is green.** + +Run: `mvn test -Dtest='*FormUrlEncodedParser*,*RequestPreparationFilter*' -q` +Expected: all pass. + +- [ ] **Step 2: Create `FormBodyCoercion` with the coercion loop lifted verbatim from `FormUrlEncodedParser.parseAndCoerce`.** + +Create `src/main/java/com/retailsvc/http/internal/FormBodyCoercion.java`: + +```java +package com.retailsvc.http.internal; + +import com.retailsvc.http.spec.schema.ArraySchema; +import com.retailsvc.http.spec.schema.ObjectSchema; +import com.retailsvc.http.spec.schema.Schema; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Coerces string-typed values produced by {@link FormUrlEncodedParser} into the Java types described + * by the body schema (numbers, booleans, arrays). Called by {@link RequestPreparationFilter} after + * parsing, before validation. + */ +final class FormBodyCoercion { + + private FormBodyCoercion() {} + + static Map coerce(Map parsed, Schema schema) { + if (!(schema instanceof ObjectSchema obj)) { + return parsed; + } + Map properties = obj.properties(); + for (Map.Entry e : parsed.entrySet()) { + Schema propSchema = properties.get(e.getKey()); + if (propSchema == null) { + continue; + } + String pointer = "/" + e.getKey(); + Object value = e.getValue(); + if (propSchema instanceof ArraySchema arr && value instanceof List list) { + List coerced = new ArrayList<>(list.size()); + for (int i = 0; i < list.size(); i++) { + coerced.add(ValueCoercion.coerce((String) list.get(i), arr.items(), pointer + "/" + i)); + } + e.setValue(coerced); + } else if (propSchema instanceof ArraySchema arr && value instanceof String s) { + e.setValue(List.of(ValueCoercion.coerce(s, arr.items(), pointer + "/0"))); + } else if (value instanceof String s) { + e.setValue(ValueCoercion.coerce(s, propSchema, pointer)); + } + } + return parsed; + } +} +``` + +- [ ] **Step 3: Remove `parseAndCoerce` from `FormUrlEncodedParser`.** + +Edit `src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java`: delete the `parseAndCoerce` method and all imports that become unused (`ArraySchema`, `ObjectSchema`, `Schema`, `ValueCoercion`, `ArrayList`). `parse(byte[], String)` stays. + +- [ ] **Step 4: Update `RequestPreparationFilter.validateAndParseBody` to call the new helper.** + +At `src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java:153-159`, replace the `switch` arm for form with two steps: + +```java +Object parsed = + switch (mediaType) { + case "application/x-www-form-urlencoded" -> + FormBodyCoercion.coerce(formParser.parse(body, header), mt.schema()); + case "text/plain" -> textParser.parse(body, header); + default -> jsonMapper.mapFrom(body); + }; +``` + +- [ ] **Step 5: Run tests; everything still passes.** + +Run: `mvn test -q` +Expected: BUILD SUCCESS. + +- [ ] **Step 6: Commit.** + +```bash +git add src/main/java/com/retailsvc/http/internal/FormBodyCoercion.java \ + src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java \ + src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java +git commit -m "refactor: Extract form-body schema coercion from FormUrlEncodedParser" +``` + +--- + +## Task 2: Introduce the `TypeMapper` interface + +A small, isolated step: introduce the new public interface with no consumers yet so subsequent tasks can implement it. + +**Files:** +- Create: `src/main/java/com/retailsvc/http/TypeMapper.java` + +- [ ] **Step 1: Write a compile-only test that asserts the interface exists with the expected shape.** + +Create `src/test/java/com/retailsvc/http/TypeMapperShapeTest.java`: + +```java +package com.retailsvc.http; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class TypeMapperShapeTest { + + @Test + void roundTrips_viaInlineImplementation() { + TypeMapper identity = + new TypeMapper() { + @Override + public Object readFrom(byte[] body, String contentTypeHeader) { + return new String(body, StandardCharsets.UTF_8); + } + + @Override + public byte[] writeTo(Object value) { + return ((String) value).getBytes(StandardCharsets.UTF_8); + } + }; + + Object read = identity.readFrom("hi".getBytes(StandardCharsets.UTF_8), "text/plain"); + assertThat(read).isEqualTo("hi"); + assertThat(identity.writeTo("hi")).containsExactly('h', 'i'); + } +} +``` + +- [ ] **Step 2: Run the test; it fails because `TypeMapper` does not yet exist.** + +Run: `mvn test -Dtest=TypeMapperShapeTest -q` +Expected: compile error / "cannot find symbol TypeMapper". + +- [ ] **Step 3: Create the interface.** + +Create `src/main/java/com/retailsvc/http/TypeMapper.java`: + +```java +package com.retailsvc.http; + +/** + * Reads and writes request/response bodies for a specific media type. Registered on {@link + * OpenApiServer.Builder#bodyMapper(String, TypeMapper)} keyed by media type. The library ships + * built-in mappers for {@code application/x-www-form-urlencoded} and {@code text/plain}; an + * {@code application/json} mapper must be supplied by the caller or auto-detected via Gson on the + * classpath. + */ +public interface TypeMapper { + + /** + * @param body raw request body bytes + * @param contentTypeHeader the full raw {@code Content-Type} header, used for charset and other + * parameters (the JSON mapper ignores it) + */ + Object readFrom(byte[] body, String contentTypeHeader); + + /** Serializes {@code value} to bytes suitable for writing as the response body. */ + byte[] writeTo(Object value); +} +``` + +- [ ] **Step 4: Run the test; it passes.** + +Run: `mvn test -Dtest=TypeMapperShapeTest -q` +Expected: PASS. + +- [ ] **Step 5: Commit.** + +```bash +git add src/main/java/com/retailsvc/http/TypeMapper.java \ + src/test/java/com/retailsvc/http/TypeMapperShapeTest.java +git commit -m "feat: Add TypeMapper interface" +``` + +--- + +## Task 3: Built-in `FormTypeMapper` and `TextTypeMapper` + +These wrap the existing `FormUrlEncodedParser` and `TextPlainParser` and add a `writeTo` implementation. Form's `writeTo` throws `UnsupportedOperationException` (per spec); text's encodes `String.valueOf(value)` to UTF-8. + +**Files:** +- Create: `src/main/java/com/retailsvc/http/internal/FormTypeMapper.java` +- Create: `src/main/java/com/retailsvc/http/internal/TextTypeMapper.java` +- Create: `src/test/java/com/retailsvc/http/internal/FormTypeMapperTest.java` +- Create: `src/test/java/com/retailsvc/http/internal/TextTypeMapperTest.java` + +- [ ] **Step 1: Failing test for `FormTypeMapper`.** + +Create `src/test/java/com/retailsvc/http/internal/FormTypeMapperTest.java`: + +```java +package com.retailsvc.http.internal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class FormTypeMapperTest { + + private final FormTypeMapper mapper = new FormTypeMapper(); + + @Test + void readsKeyValuePairs() { + byte[] body = "name=Alice&color=blue".getBytes(StandardCharsets.UTF_8); + Object parsed = mapper.readFrom(body, "application/x-www-form-urlencoded"); + assertThat(parsed).isInstanceOf(Map.class); + @SuppressWarnings("unchecked") + Map m = (Map) parsed; + assertThat(m).containsEntry("name", "Alice").containsEntry("color", "blue"); + } + + @Test + void writeTo_isUnsupported() { + assertThatThrownBy(() -> mapper.writeTo(Map.of("k", "v"))) + .isInstanceOf(UnsupportedOperationException.class); + } +} +``` + +Run: `mvn test -Dtest=FormTypeMapperTest -q` +Expected: compile error (no FormTypeMapper yet). + +- [ ] **Step 2: Implement `FormTypeMapper`.** + +Create `src/main/java/com/retailsvc/http/internal/FormTypeMapper.java`: + +```java +package com.retailsvc.http.internal; + +import com.retailsvc.http.TypeMapper; + +/** + * Built-in {@link TypeMapper} for {@code application/x-www-form-urlencoded}. Reads delegate to + * {@link FormUrlEncodedParser}. Writes are not supported — form-encoded responses are unusual and + * intentionally left out until a real need surfaces. + */ +public final class FormTypeMapper implements TypeMapper { + + private final FormUrlEncodedParser parser = new FormUrlEncodedParser(); + + @Override + public Object readFrom(byte[] body, String contentTypeHeader) { + return parser.parse(body, contentTypeHeader); + } + + @Override + public byte[] writeTo(Object value) { + throw new UnsupportedOperationException( + "application/x-www-form-urlencoded write is not supported; register a custom TypeMapper"); + } +} +``` + +- [ ] **Step 3: Verify form test passes.** + +Run: `mvn test -Dtest=FormTypeMapperTest -q` +Expected: PASS. + +- [ ] **Step 4: Failing test for `TextTypeMapper`.** + +Create `src/test/java/com/retailsvc/http/internal/TextTypeMapperTest.java`: + +```java +package com.retailsvc.http.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class TextTypeMapperTest { + + private final TextTypeMapper mapper = new TextTypeMapper(); + + @Test + void reads_utf8_default() { + byte[] body = "hello".getBytes(StandardCharsets.UTF_8); + assertThat(mapper.readFrom(body, "text/plain")).isEqualTo("hello"); + } + + @Test + void reads_explicitCharset() { + byte[] body = "räksmörgås".getBytes(StandardCharsets.ISO_8859_1); + assertThat(mapper.readFrom(body, "text/plain; charset=ISO-8859-1")).isEqualTo("räksmörgås"); + } + + @Test + void writes_stringValueAsUtf8() { + assertThat(mapper.writeTo("ok")).isEqualTo("ok".getBytes(StandardCharsets.UTF_8)); + assertThat(mapper.writeTo(42)).isEqualTo("42".getBytes(StandardCharsets.UTF_8)); + assertThat(mapper.writeTo(null)).isEqualTo("null".getBytes(StandardCharsets.UTF_8)); + } +} +``` + +- [ ] **Step 5: Implement `TextTypeMapper`.** + +Create `src/main/java/com/retailsvc/http/internal/TextTypeMapper.java`: + +```java +package com.retailsvc.http.internal; + +import com.retailsvc.http.TypeMapper; +import java.nio.charset.StandardCharsets; + +/** + * Built-in {@link TypeMapper} for {@code text/plain}. Reads decode bytes using the charset declared + * on {@code Content-Type} (default UTF-8). Writes return {@code String.valueOf(value)} encoded as + * UTF-8. + */ +public final class TextTypeMapper implements TypeMapper { + + private final TextPlainParser parser = new TextPlainParser(); + + @Override + public Object readFrom(byte[] body, String contentTypeHeader) { + return parser.parse(body, contentTypeHeader); + } + + @Override + public byte[] writeTo(Object value) { + return String.valueOf(value).getBytes(StandardCharsets.UTF_8); + } +} +``` + +- [ ] **Step 6: Run tests; both pass.** + +Run: `mvn test -Dtest='FormTypeMapperTest,TextTypeMapperTest' -q` +Expected: PASS. + +- [ ] **Step 7: Commit.** + +```bash +git add src/main/java/com/retailsvc/http/internal/FormTypeMapper.java \ + src/main/java/com/retailsvc/http/internal/TextTypeMapper.java \ + src/test/java/com/retailsvc/http/internal/FormTypeMapperTest.java \ + src/test/java/com/retailsvc/http/internal/TextTypeMapperTest.java +git commit -m "feat: Built-in FormTypeMapper and TextTypeMapper" +``` + +--- + +## Task 4: Promote Gson to optional compile dependency + +Today Gson is in test scope. Move it to compile scope with `true` so: +- the library can ship `GsonJsonMapper`; +- downstream consumers do not pull Gson transitively (so Jackson users are unaffected). + +**Files:** +- Modify: `pom.xml` lines 60-64. + +- [ ] **Step 1: Edit the Gson dependency.** + +Replace the existing Gson `` block in `pom.xml` (lines 60-64) with: + +```xml + + com.google.code.gson + gson + 2.14.0 + true + +``` + +- [ ] **Step 2: Sort the POM (the sortpom plugin runs at `validate` and will fail the build otherwise).** + +Run: `mvn sortpom:sort -q` +Expected: BUILD SUCCESS. + +- [ ] **Step 3: Run the full unit test suite — Gson is still on the test classpath (optional deps are still pulled in for the declaring module's tests), so nothing should break.** + +Run: `mvn test -q` +Expected: BUILD SUCCESS. + +- [ ] **Step 4: Commit.** + +```bash +git add pom.xml +git commit -m "build: Make Gson an optional compile dependency" +``` + +--- + +## Task 5: `GsonJsonMapper` with integer-preserving and JSR-310 adapters + +A library-owned `TypeMapper` for `application/json`, backed by Gson, never instantiated unless the builder's classpath probe finds Gson at runtime. Custom `TypeAdapter` for integer preservation; per-type write adapters for `Instant`, `OffsetDateTime`, `ZonedDateTime`, `LocalDateTime`, `LocalDate`, `LocalTime`. + +**Files:** +- Create: `src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java` +- Create: `src/test/java/com/retailsvc/http/internal/gson/GsonJsonMapperTest.java` + +- [ ] **Step 1: Failing test covering read (integer preservation, fractional double, basic types) and write (JSR-310 ISO-8601 emission).** + +Create `src/test/java/com/retailsvc/http/internal/gson/GsonJsonMapperTest.java`: + +```java +package com.retailsvc.http.internal.gson; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class GsonJsonMapperTest { + + private final GsonJsonMapper mapper = new GsonJsonMapper(); + + @Test + void read_preservesIntegersAsLong() { + @SuppressWarnings("unchecked") + Map parsed = + (Map) mapper.readFrom(bytes("{\"n\":42}"), "application/json"); + assertThat(parsed.get("n")).isEqualTo(42L).isInstanceOf(Long.class); + } + + @Test + void read_keepsFractionalAsDouble() { + @SuppressWarnings("unchecked") + Map parsed = + (Map) mapper.readFrom(bytes("{\"n\":1.5}"), "application/json"); + assertThat(parsed.get("n")).isEqualTo(1.5).isInstanceOf(Double.class); + } + + @Test + void read_basicTypes() { + @SuppressWarnings("unchecked") + Map parsed = + (Map) + mapper.readFrom( + bytes("{\"s\":\"hi\",\"b\":true,\"n\":null,\"a\":[1,2]}"), + "application/json"); + assertThat(parsed.get("s")).isEqualTo("hi"); + assertThat(parsed.get("b")).isEqualTo(Boolean.TRUE); + assertThat(parsed.get("n")).isNull(); + assertThat(parsed.get("a")).isEqualTo(List.of(1L, 2L)); + } + + @Test + void write_mapAndList() { + byte[] out = mapper.writeTo(Map.of("k", List.of(1L, 2L))); + assertThat(new String(out, StandardCharsets.UTF_8)).isEqualTo("{\"k\":[1,2]}"); + } + + @Test + void write_instantAsIso8601() { + Instant t = Instant.parse("2026-05-13T10:00:00Z"); + assertThat(new String(mapper.writeTo(Map.of("ts", t)), StandardCharsets.UTF_8)) + .isEqualTo("{\"ts\":\"2026-05-13T10:00:00Z\"}"); + } + + @Test + void write_offsetDateTimeAsIso8601() { + OffsetDateTime t = OffsetDateTime.of(2026, 5, 13, 10, 0, 0, 0, ZoneOffset.UTC); + assertThat(new String(mapper.writeTo(Map.of("ts", t)), StandardCharsets.UTF_8)) + .isEqualTo("{\"ts\":\"2026-05-13T10:00Z\"}"); + } + + @Test + void write_zonedDateTimeAsIso8601() { + ZonedDateTime t = ZonedDateTime.of(2026, 5, 13, 10, 0, 0, 0, ZoneOffset.UTC); + assertThat(new String(mapper.writeTo(Map.of("ts", t)), StandardCharsets.UTF_8)) + .contains("2026-05-13T10:00Z"); + } + + @Test + void write_localDateTimeAsIso8601() { + assertThat( + new String( + mapper.writeTo(Map.of("ts", LocalDateTime.of(2026, 5, 13, 10, 0))), + StandardCharsets.UTF_8)) + .isEqualTo("{\"ts\":\"2026-05-13T10:00\"}"); + } + + @Test + void write_localDateAsIso8601() { + assertThat( + new String( + mapper.writeTo(Map.of("d", LocalDate.of(2026, 5, 13))), StandardCharsets.UTF_8)) + .isEqualTo("{\"d\":\"2026-05-13\"}"); + } + + @Test + void write_localTimeAsIso8601() { + assertThat( + new String(mapper.writeTo(Map.of("t", LocalTime.of(10, 0))), StandardCharsets.UTF_8)) + .isEqualTo("{\"t\":\"10:00\"}"); + } + + private static byte[] bytes(String s) { + return s.getBytes(StandardCharsets.UTF_8); + } +} +``` + +Run: `mvn test -Dtest=GsonJsonMapperTest -q` +Expected: compile error (no GsonJsonMapper yet). + +- [ ] **Step 2: Implement `GsonJsonMapper`.** + +Create `src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java`: + +```java +package com.retailsvc.http.internal.gson; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; +import com.retailsvc.http.TypeMapper; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Built-in {@link TypeMapper} for {@code application/json} backed by Gson. Auto-registered by + * {@link com.retailsvc.http.OpenApiServer.Builder} when Gson is on the classpath and no + * user-supplied JSON mapper has been registered. + * + *

The default {@code Object} {@link TypeAdapter} is replaced with one that returns {@code Long} + * for integral JSON numbers and {@code Double} for fractional numbers, so the library's integer + * schemas validate as expected. JSR-310 types ({@code Instant}, {@code OffsetDateTime}, + * {@code ZonedDateTime}, {@code LocalDateTime}, {@code LocalDate}, {@code LocalTime}) are written + * as their ISO-8601 string form. + */ +public final class GsonJsonMapper implements TypeMapper { + + private final Gson gson; + + public GsonJsonMapper() { + this.gson = + new GsonBuilder() + .registerTypeAdapter(Object.class, new IntegerPreservingObjectAdapter()) + .registerTypeAdapter(Instant.class, isoStringWriter(Instant::toString)) + .registerTypeAdapter(OffsetDateTime.class, isoStringWriter(OffsetDateTime::toString)) + .registerTypeAdapter(ZonedDateTime.class, isoStringWriter(ZonedDateTime::toString)) + .registerTypeAdapter(LocalDateTime.class, isoStringWriter(LocalDateTime::toString)) + .registerTypeAdapter(LocalDate.class, isoStringWriter(LocalDate::toString)) + .registerTypeAdapter(LocalTime.class, isoStringWriter(LocalTime::toString)) + .create(); + } + + @Override + public Object readFrom(byte[] body, String contentTypeHeader) { + return gson.fromJson(new String(body, StandardCharsets.UTF_8), Object.class); + } + + @Override + public byte[] writeTo(Object value) { + return gson.toJson(value).getBytes(StandardCharsets.UTF_8); + } + + private static TypeAdapter isoStringWriter(java.util.function.Function toIso) { + return new TypeAdapter() { + @Override + public void write(JsonWriter out, T value) throws IOException { + if (value == null) { + out.nullValue(); + } else { + out.value(toIso.apply(value)); + } + } + + @Override + public T read(JsonReader in) { + throw new UnsupportedOperationException( + "GsonJsonMapper does not parse JSR-310 types; values arrive as String"); + } + }; + } + + private static final class IntegerPreservingObjectAdapter extends TypeAdapter { + + @Override + public Object read(JsonReader in) throws IOException { + JsonToken token = in.peek(); + switch (token) { + case BEGIN_ARRAY -> { + List list = new ArrayList<>(); + in.beginArray(); + while (in.hasNext()) { + list.add(read(in)); + } + in.endArray(); + return list; + } + case BEGIN_OBJECT -> { + Map map = new LinkedHashMap<>(); + in.beginObject(); + while (in.hasNext()) { + map.put(in.nextName(), read(in)); + } + in.endObject(); + return map; + } + case STRING -> { + return in.nextString(); + } + case NUMBER -> { + String raw = in.nextString(); + if (raw.indexOf('.') < 0 && raw.indexOf('e') < 0 && raw.indexOf('E') < 0) { + try { + return Long.parseLong(raw); + } catch (NumberFormatException _) { + // falls through to Double for out-of-range integers + } + } + return Double.parseDouble(raw); + } + case BOOLEAN -> { + return in.nextBoolean(); + } + case NULL -> { + in.nextNull(); + return null; + } + default -> throw new IllegalStateException("Unexpected token: " + token); + } + } + + @Override + public void write(JsonWriter out, Object value) throws IOException { + // Delegate to Gson's default Object serialization by writing values manually. + if (value == null) { + out.nullValue(); + } else if (value instanceof Map map) { + out.beginObject(); + for (Map.Entry e : map.entrySet()) { + out.name(String.valueOf(e.getKey())); + write(out, e.getValue()); + } + out.endObject(); + } else if (value instanceof Iterable it) { + out.beginArray(); + for (Object e : it) { + write(out, e); + } + out.endArray(); + } else if (value instanceof Number n) { + if (n instanceof Long || n instanceof Integer || n instanceof Short || n instanceof Byte) { + out.value(n.longValue()); + } else { + out.value(n.doubleValue()); + } + } else if (value instanceof Boolean b) { + out.value(b); + } else if (value instanceof String s) { + out.value(s); + } else { + // Fall back to Gson default for unknown types (JSR-310 adapters take priority). + out.value(value.toString()); + } + } + } +} +``` + +- [ ] **Step 3: Run the test; it passes.** + +Run: `mvn test -Dtest=GsonJsonMapperTest -q` +Expected: PASS (all assertions). + +- [ ] **Step 4: Commit.** + +```bash +git add src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java \ + src/test/java/com/retailsvc/http/internal/gson/GsonJsonMapperTest.java +git commit -m "feat: GsonJsonMapper with integer-preserving and JSR-310 adapters" +``` + +--- + +## Task 6: Builder switch to `bodyMapper(...)`; delete `JsonMapper`; rewire filter + +The atomic API cutover for the read pipeline: + +1. `OpenApiServer.Builder.bodyMapper(String, TypeMapper)` replaces `jsonMapper(JsonMapper)`. +2. Builder wires defaults for form + text; probes `com.google.gson.Gson` and registers `GsonJsonMapper` if absent. +3. `RequestPreparationFilter` dispatches via `Map`. +4. `JsonMapper` and `JsonMapperTest` are deleted. +5. `OpenApiServer` constructors drop the `JsonMapper` parameter. +6. Test base, builder test, and ServerLauncher updated. + +Compile breaks mid-task; we fix everything in one commit. Handler API (`Map`) is **not** touched here — that comes in Task 9. + +**Files:** +- Modify: `src/main/java/com/retailsvc/http/OpenApiServer.java` (constructors + builder fields + builder methods + `build()`) +- Modify: `src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java` (constructor signature + `validateAndParseBody` dispatch) +- Delete: `src/main/java/com/retailsvc/http/JsonMapper.java` +- Delete: `src/test/java/com/retailsvc/http/JsonMapperTest.java` +- Modify: `src/test/java/com/retailsvc/http/ServerBaseTest.java` +- Modify: `src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java` +- Modify: `src/test/java/com/retailsvc/http/start/ServerLauncher.java` +- Create: `src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java` + +- [ ] **Step 1: Write the registration tests (RED).** + +Create `src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java`: + +```java +package com.retailsvc.http; + +import static java.net.http.HttpClient.Version.HTTP_1_1; +import static java.net.http.HttpResponse.BodyHandlers.ofString; +import static java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.sun.net.httpserver.HttpHandler; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublishers; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class TypeMapperRegistrationTest extends ServerBaseTest { + + @Test + void gsonFallback_isAutoRegisteredWhenNoJsonMapperConfigured() throws Exception { + HttpHandler echo = + ex -> { + Object parsed = Request.parsed(); + byte[] out = gson.toJson(parsed).getBytes(StandardCharsets.UTF_8); + ex.getResponseHeaders().add("Content-Type", "application/json"); + ex.sendResponseHeaders(200, out.length); + ex.getResponseBody().write(out); + ex.close(); + }; + server = + OpenApiServer.builder() + .spec(spec) + .handlers(Map.of("getRoot", echo, "postData", echo)) + .port(0) + .build(); + HttpClient client = + HttpClient.newBuilder() + .executor(newVirtualThreadPerTaskExecutor()) + .version(HTTP_1_1) + .build(); + var resp = + client.send( + HttpRequest.newBuilder() + .uri(URI.create("http://localhost:%d/api/v1/data".formatted(server.listenPort()))) + .header("Content-Type", "application/json") + .POST(BodyPublishers.ofString("{\"n\":42}")) + .build(), + ofString()); + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.body()).contains("\"n\":42"); + } + + @Test + void userSuppliedMapper_overridesDefault() throws Exception { + TypeMapper marker = new TypeMapper() { + @Override public Object readFrom(byte[] b, String h) { return Map.of("from", "custom"); } + @Override public byte[] writeTo(Object v) { return "ignored".getBytes(StandardCharsets.UTF_8); } + }; + HttpHandler echo = + ex -> { + ex.sendResponseHeaders(200, -1); + ex.close(); + }; + OpenApiServer s = + OpenApiServer.builder() + .spec(spec) + .bodyMapper("application/json", marker) + .handlers(Map.of("getRoot", echo, "postData", echo)) + .port(0) + .build(); + s.close(); + } + + @Test + void bodyMapper_rejectsNullArgs() { + var b = OpenApiServer.builder(); + assertThatThrownBy(() -> b.bodyMapper(null, new GsonOnlyMapper())) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> b.bodyMapper("application/json", null)) + .isInstanceOf(NullPointerException.class); + } + + private static final class GsonOnlyMapper implements TypeMapper { + @Override public Object readFrom(byte[] b, String h) { return null; } + @Override public byte[] writeTo(Object v) { return new byte[0]; } + } +} +``` + +Run: `mvn test -Dtest=TypeMapperRegistrationTest -q` +Expected: compile error (no `bodyMapper` method, no `Builder.spec`, etc.). + +- [ ] **Step 2: Update `RequestPreparationFilter` to take `Map`.** + +In `src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java`: + +- Remove the import of `com.retailsvc.http.JsonMapper`. +- Add import of `com.retailsvc.http.TypeMapper`. +- Replace fields: + +```java +private final Spec spec; +private final Router router; +private final Validator validator; +private final Map bodyMappers; +``` + +- Replace constructor: + +```java +public RequestPreparationFilter( + Spec spec, Router router, Validator validator, Map bodyMappers) { + this.spec = spec; + this.router = router; + this.validator = validator; + this.bodyMappers = Map.copyOf(bodyMappers); +} +``` + +- Delete the `formParser` and `textParser` fields (their behaviour moves into the registered mappers). +- Replace `validateAndParseBody`'s switch body: + +```java +TypeMapper mapper = bodyMappers.get(mediaType); +if (mapper == null) { + throw new ValidationException( + new ValidationError( + "/body", "content-type", "unsupported content type: " + mediaType, null)); +} +Object parsed = mapper.readFrom(body, header); +if (mediaType.equals("application/x-www-form-urlencoded") && parsed instanceof Map map) { + @SuppressWarnings("unchecked") + Map typed = (Map) map; + parsed = FormBodyCoercion.coerce(typed, mt.schema()); +} +validator.validate(parsed, mt.schema(), ""); +return parsed; +``` + +Note: the "unsupported content type" check now happens after the spec content-type check; both kinds of mismatch produce the same `ValidationException` shape. The existing `MediaType mt = rb.get().content().get(mediaType);` block immediately above stays — keep both as defence in depth. + +- [ ] **Step 3: Rewrite `OpenApiServer` and its `Builder`.** + +In `src/main/java/com/retailsvc/http/OpenApiServer.java`: + +- Replace the `JsonMapper jsonMapper` constructor parameter (3 constructors) with `Map bodyMappers`. +- The public 4-arg and 5-arg constructors are removed; only the package-private full constructor remains. Add a public 1-arg / 2-arg pair for direct construction if needed (the test base only uses the builder, so keep the surface minimal — see Step 5). + +Replace the entire file content with: + +```java +package com.retailsvc.http; + +import static java.lang.Thread.ofVirtual; +import static java.util.Objects.requireNonNull; +import static java.util.concurrent.Executors.newThreadPerTaskExecutor; + +import com.retailsvc.http.internal.DispatchHandler; +import com.retailsvc.http.internal.ExceptionFilter; +import com.retailsvc.http.internal.FormTypeMapper; +import com.retailsvc.http.internal.RequestPreparationFilter; +import com.retailsvc.http.internal.Router; +import com.retailsvc.http.internal.TextTypeMapper; +import com.retailsvc.http.spec.Spec; +import com.retailsvc.http.validate.DefaultValidator; +import com.sun.net.httpserver.HttpContext; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class OpenApiServer implements AutoCloseable { + + private static final Logger LOG = LoggerFactory.getLogger(OpenApiServer.class); + private static final int DEFAULT_PORT = 8080; + private static final String JSON = "application/json"; + private static final String GSON_CLASS = "com.google.gson.Gson"; + private static final String GSON_MAPPER_CLASS = + "com.retailsvc.http.internal.gson.GsonJsonMapper"; + + private final HttpServer httpServer; + private final int shutdownTimeoutSeconds; + + OpenApiServer( + Spec spec, + Map bodyMappers, + Map handlers, + ExceptionHandler exceptionHandler, + int port, + Map extras, + int shutdownTimeoutSeconds) + throws IOException { + + requireNonNull(spec, "Spec must not be null"); + requireNonNull(bodyMappers, "bodyMappers must not be null"); + requireNonNull(handlers, "handlers must not be null"); + if (exceptionHandler == null) { + LOG.warn("No ExceptionHandler set, using default"); + exceptionHandler = Handlers.defaultExceptionHandler(); + } + + long t0 = System.currentTimeMillis(); + Router router = new Router(spec.operations()); + DefaultValidator validator = new DefaultValidator(spec::resolveSchema); + + this.httpServer = HttpServer.create(new InetSocketAddress(port), 0); + httpServer.setExecutor(newThreadPerTaskExecutor(ofVirtual().name("http-", 0).factory())); + + HttpContext ctx = httpServer.createContext(Optional.ofNullable(spec.basePath()).orElse("/")); + ctx.getFilters().add(new ExceptionFilter(exceptionHandler)); + ctx.getFilters().add(new RequestPreparationFilter(spec, router, validator, bodyMappers)); + ctx.setHandler(new DispatchHandler(handlers)); + + for (Map.Entry e : extras.entrySet()) { + HttpContext extraCtx = httpServer.createContext(e.getKey()); + extraCtx.getFilters().add(new ExceptionFilter(exceptionHandler)); + extraCtx.setHandler(e.getValue()); + } + + httpServer.createContext("/", Handlers.notFoundHandler()); + httpServer.start(); + + this.shutdownTimeoutSeconds = shutdownTimeoutSeconds; + + LOG.info("Server started (port {}) in {}ms", port, System.currentTimeMillis() - t0); + } + + public int listenPort() { + return httpServer.getAddress().getPort(); + } + + public void stop(int delaySeconds) { + if (delaySeconds < 0) { + throw new IllegalArgumentException("delaySeconds must be non-negative, got " + delaySeconds); + } + if (httpServer != null) { + httpServer.stop(delaySeconds); + } + } + + @Override + public void close() { + stop(shutdownTimeoutSeconds); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private Spec spec; + private final LinkedHashMap bodyMappers = new LinkedHashMap<>(); + private Map handlers; + private ExceptionHandler exceptionHandler; + private int port = DEFAULT_PORT; + private int shutdownTimeoutSeconds = 0; + private final LinkedHashMap extras = new LinkedHashMap<>(); + + private Builder() {} + + public Builder spec(Spec spec) { + this.spec = spec; + return this; + } + + public Builder bodyMapper(String mediaType, TypeMapper mapper) { + requireNonNull(mediaType, "mediaType must not be null"); + requireNonNull(mapper, "mapper must not be null"); + bodyMappers.put(mediaType.toLowerCase(java.util.Locale.ROOT), mapper); + return this; + } + + public Builder handlers(Map handlers) { + this.handlers = handlers; + return this; + } + + public Builder exceptionHandler(ExceptionHandler exceptionHandler) { + this.exceptionHandler = exceptionHandler; + return this; + } + + public Builder port(int port) { + this.port = port; + return this; + } + + public Builder shutdownTimeoutSeconds(int shutdownTimeoutSeconds) { + if (shutdownTimeoutSeconds < 0) { + throw new IllegalArgumentException( + "shutdownTimeoutSeconds must be non-negative, got " + shutdownTimeoutSeconds); + } + this.shutdownTimeoutSeconds = shutdownTimeoutSeconds; + return this; + } + + public Builder addHandler(String path, HttpHandler handler) { + requireNonNull(path, "path must not be null"); + requireNonNull(handler, "handler must not be null"); + if (extras.containsKey(path)) { + throw new IllegalStateException("duplicate extra handler path: " + path); + } + extras.put(path, handler); + return this; + } + + public OpenApiServer build() throws IOException { + requireNonNull(spec, "Spec must not be null"); + requireNonNull(handlers, "handlers must not be null"); + String basePath = Optional.ofNullable(spec.basePath()).orElse("/"); + for (String path : extras.keySet()) { + if (path.equals(basePath)) { + throw new IllegalStateException( + "extra handler path " + path + " conflicts with spec basePath " + basePath); + } + } + Map resolved = resolveBodyMappers(bodyMappers); + return new OpenApiServer( + spec, resolved, handlers, exceptionHandler, port, extras, shutdownTimeoutSeconds); + } + + private static Map resolveBodyMappers( + Map userSupplied) { + LinkedHashMap out = new LinkedHashMap<>(); + out.put("application/x-www-form-urlencoded", new FormTypeMapper()); + out.put("text/plain", new TextTypeMapper()); + out.putAll(userSupplied); + if (!out.containsKey(JSON)) { + TypeMapper fallback = tryLoadGsonMapper(); + if (fallback != null) { + out.put(JSON, fallback); + } + } + if (!out.containsKey(JSON)) { + throw new IllegalStateException( + "No TypeMapper registered for application/json and Gson not found on classpath; " + + "register one via Builder.bodyMapper(\"application/json\", ...)"); + } + return out; + } + + private static TypeMapper tryLoadGsonMapper() { + try { + Class.forName(GSON_CLASS, false, OpenApiServer.class.getClassLoader()); + } catch (ClassNotFoundException _) { + return null; + } + try { + Class cls = + Class.forName(GSON_MAPPER_CLASS, true, OpenApiServer.class.getClassLoader()); + return (TypeMapper) cls.getDeclaredConstructor().newInstance(); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Failed to load " + GSON_MAPPER_CLASS, e); + } + } + } +} +``` + +- [ ] **Step 4: Delete `JsonMapper.java` and `JsonMapperTest.java`.** + +```bash +git rm src/main/java/com/retailsvc/http/JsonMapper.java src/test/java/com/retailsvc/http/JsonMapperTest.java +``` + +- [ ] **Step 5: Update `ServerBaseTest` to use the builder.** + +In `src/test/java/com/retailsvc/http/ServerBaseTest.java`: + +- Remove the `jsonMapper()` method. +- Replace `newServer(...)`: + +```java +protected OpenApiServer newServer(Map handlers) { + try { + server = + OpenApiServer.builder() + .spec(spec) + .handlers(handlers) + .port(0) + .build(); + return server; + } catch (Exception e) { + fail(e); + } + return null; +} +``` + +The Gson fallback covers the `application/json` mapper (Gson is on the test classpath). + +- [ ] **Step 6: Update `ServerLauncher` and `OpenApiServerBuilderTest`.** + +In `src/test/java/com/retailsvc/http/start/ServerLauncher.java`: + +- Remove the import of `com.retailsvc.http.JsonMapper`. +- Remove `import com.google.gson.Gson;` if no longer needed (the Gson fallback handles it). +- Replace lines around 29-45 that build the `JsonMapper` and pass it to `OpenApiServer.builder()`. The launcher should simply call `.spec(spec).handlers(handlers).build()` and rely on the Gson fallback. (Concrete content depends on the current launcher shape; the rule is: no `JsonMapper` reference, no explicit JSON mapper registration.) + +In `src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java`: find each call to `.jsonMapper(...)` and replace with `.bodyMapper("application/json", ...)`. Any test that explicitly verified `jsonMapper(null)` throws moves under `bodyMapper(...)` null checks. If a test asserted on `JsonMapper`'s type, delete it. + +- [ ] **Step 7: Run the full unit suite.** + +Run: `mvn test -q` +Expected: BUILD SUCCESS. + +- [ ] **Step 8: Run integration tests.** + +Run: `mvn verify -q` +Expected: BUILD SUCCESS. + +- [ ] **Step 9: Commit.** + +```bash +git add -u +git add src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java +git commit -m "feat!: Replace JsonMapper with bodyMapper(mediaType, TypeMapper) + Gson fallback" +``` + +--- + +## Task 7: Move legacy `Request` static accessors out of the way + +Before introducing the new `Request` (per-request handle), rename the existing static-accessor class so the new class can take the canonical name without ambiguity during the cutover. + +**Files:** +- Rename: `src/main/java/com/retailsvc/http/Request.java` → `src/main/java/com/retailsvc/http/internal/LegacyRequestAccess.java` (move to `internal` package; rename class). +- Modify: every reference: filter, dispatcher, tests, launcher. + +- [ ] **Step 1: Move the file with `git mv` and rename the class.** + +```bash +git mv src/main/java/com/retailsvc/http/Request.java src/main/java/com/retailsvc/http/internal/LegacyRequestAccess.java +``` + +- [ ] **Step 2: Edit the moved file.** + +Replace `package com.retailsvc.http;` with `package com.retailsvc.http.internal;`. Rename the class from `Request` to `LegacyRequestAccess` and make it package-private (`final class LegacyRequestAccess`). Keep the `public static final ScopedValue CONTEXT` (it's still needed by the filter for one more task), but its public-ness is no longer relevant. + +- [ ] **Step 3: Update every consumer.** + +Use a find-and-replace on `com.retailsvc.http.Request` references in `src/main/` and `src/test/`. Three categories: + +- In `src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java`: `import com.retailsvc.http.Request;` → `// (none; LegacyRequestAccess is in this package)` and `Request.CONTEXT` → `LegacyRequestAccess.CONTEXT`. +- In `src/main/java/com/retailsvc/http/internal/DispatchHandler.java`: same — switch to `LegacyRequestAccess.CONTEXT.get()` etc. +- In tests and `ServerLauncher`: replace all `Request.parsed()` / `Request.bytes()` / `Request.operationId()` / `Request.pathParams()` / `Request.current()` calls with the same names on `LegacyRequestAccess` (which now needs the static accessor methods reinstated since handlers in tests still use them): + +```java +public static byte[] bytes() { return CONTEXT.get().body(); } +public static Object parsed() { return CONTEXT.get().parsedBody(); } +public static String operationId() { return CONTEXT.get().operationId(); } +public static Map pathParams() { return CONTEXT.get().pathParameters(); } +public static RequestContext current() { return CONTEXT.get(); } +``` + +And tests / launcher import `LegacyRequestAccess` instead of `Request`. + +- [ ] **Step 4: Verify all unit + IT tests still pass.** + +Run: `mvn verify -q` +Expected: BUILD SUCCESS. + +- [ ] **Step 5: Commit.** + +```bash +git add -A +git commit -m "refactor: Move static Request accessors to internal LegacyRequestAccess" +``` + +--- + +## Task 8: Introduce new `Request`, `ResponseBuilder`, `RequestHandler` + +Add the new types without changing any existing consumer. After this task, both the legacy `LegacyRequestAccess` path *and* the new types coexist; Task 9 switches consumers over and Task 10 deletes the legacy path. + +**Files:** +- Create: `src/main/java/com/retailsvc/http/Request.java` (new class) +- Create: `src/main/java/com/retailsvc/http/RequestHandler.java` +- Create: `src/main/java/com/retailsvc/http/ResponseBuilder.java` +- Create: `src/main/java/com/retailsvc/http/internal/DefaultResponseBuilder.java` +- Create: `src/test/java/com/retailsvc/http/RequestResponseGatewayTest.java` + +- [ ] **Step 1: Write the failing response-gateway test.** + +Create `src/test/java/com/retailsvc/http/RequestResponseGatewayTest.java`: + +```java +package com.retailsvc.http; + +import static java.net.http.HttpClient.Version.HTTP_1_1; +import static java.net.http.HttpResponse.BodyHandlers.ofString; +import static java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor; +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublishers; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class RequestResponseGatewayTest extends ServerBaseTest { + + @Test + void respondJson_writesBodyAndContentType() throws Exception { + RequestHandler echo = + req -> req.respond(200).json(Map.of("op", req.operationId())); + server = + OpenApiServer.builder() + .spec(spec) + .handlers(Map.of("getRoot", echo, "postData", echo)) + .port(0) + .build(); + HttpClient client = + HttpClient.newBuilder() + .executor(newVirtualThreadPerTaskExecutor()) + .version(HTTP_1_1) + .build(); + var resp = + client.send( + HttpRequest.newBuilder() + .uri(URI.create("http://localhost:%d/api/v1/data".formatted(server.listenPort()))) + .header("Content-Type", "application/json") + .POST(BodyPublishers.ofString("{\"n\":1}")) + .build(), + ofString()); + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.headers().firstValue("Content-Type")).contains("application/json"); + assertThat(resp.body()).contains("\"op\":\"postData\""); + } + + @Test + void respondEmpty_uses204Style() throws Exception { + RequestHandler ok = req -> req.respond(204).empty(); + server = + OpenApiServer.builder() + .spec(spec) + .handlers(Map.of("getRoot", ok, "postData", ok)) + .port(0) + .build(); + var resp = + HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder() + .uri(URI.create("http://localhost:%d/api/v1/".formatted(server.listenPort()))) + .GET() + .build(), + ofString()); + assertThat(resp.statusCode()).isEqualTo(204); + assertThat(resp.body()).isEmpty(); + } + + @Test + void respondStream_chunkedEncoding() throws Exception { + RequestHandler streamer = + req -> { + try (var out = req.respond(200).contentType("text/plain").stream()) { + out.write("hello ".getBytes()); + out.write("world".getBytes()); + } + }; + server = + OpenApiServer.builder() + .spec(spec) + .handlers(Map.of("getRoot", streamer, "postData", streamer)) + .port(0) + .build(); + var resp = + HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder() + .uri(URI.create("http://localhost:%d/api/v1/".formatted(server.listenPort()))) + .GET() + .build(), + ofString()); + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.body()).isEqualTo("hello world"); + } +} +``` + +(Compile fails until Steps 2-5 complete and Task 9 wires `handlers(Map)`. We accept the temporary RED state; this test goes green after Task 9.) + +Run: `mvn test -Dtest=RequestResponseGatewayTest -q` +Expected: compile error. + +- [ ] **Step 2: Create `ResponseBuilder` interface.** + +Create `src/main/java/com/retailsvc/http/ResponseBuilder.java`: + +```java +package com.retailsvc.http; + +import java.io.IOException; +import java.io.OutputStream; + +/** + * Fluent response builder returned by {@link Request#respond(int)}. Each {@code Request} permits + * exactly one terminal call ({@link #empty()}, {@link #bytes(byte[])}, {@link #text(String)}, + * {@link #json(Object)}, {@link #body(String, Object)}, {@link #stream()}, or + * {@link #stream(long)}); calling any of them after the first throws + * {@link IllegalStateException}. {@link #header(String, String)} / {@link #contentType(String)} + * must be called before the terminal. + */ +public interface ResponseBuilder { + + ResponseBuilder header(String name, String value); + + ResponseBuilder contentType(String contentType); + + void empty() throws IOException; + + void bytes(byte[] body) throws IOException; + + void text(String body) throws IOException; + + void json(Object body) throws IOException; + + void body(String mediaType, Object body) throws IOException; + + OutputStream stream() throws IOException; + + OutputStream stream(long length) throws IOException; +} +``` + +- [ ] **Step 3: Create `RequestHandler` interface.** + +Create `src/main/java/com/retailsvc/http/RequestHandler.java`: + +```java +package com.retailsvc.http; + +import java.io.IOException; + +/** + * Handles a single request identified by OpenAPI {@code operationId}. Registered on + * {@link OpenApiServer.Builder#handlers(java.util.Map)} by operation ID. + */ +@FunctionalInterface +public interface RequestHandler { + void handle(Request request) throws IOException; +} +``` + +- [ ] **Step 4: Create `DefaultResponseBuilder`.** + +Create `src/main/java/com/retailsvc/http/internal/DefaultResponseBuilder.java`: + +```java +package com.retailsvc.http.internal; + +import com.retailsvc.http.ResponseBuilder; +import com.retailsvc.http.TypeMapper; +import com.sun.net.httpserver.HttpExchange; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class DefaultResponseBuilder implements ResponseBuilder { + + private static final String CONTENT_TYPE = "Content-Type"; + + private final HttpExchange exchange; + private final int status; + private final Map mappers; + private final Map pendingHeaders = new LinkedHashMap<>(); + private boolean terminated; + + public DefaultResponseBuilder( + HttpExchange exchange, int status, Map mappers) { + this.exchange = exchange; + this.status = status; + this.mappers = mappers; + } + + @Override + public ResponseBuilder header(String name, String value) { + checkNotTerminated(); + pendingHeaders.put(name, value); + return this; + } + + @Override + public ResponseBuilder contentType(String contentType) { + return header(CONTENT_TYPE, contentType); + } + + @Override + public void empty() throws IOException { + terminate(); + applyHeaders(); + exchange.sendResponseHeaders(status, -1); + } + + @Override + public void bytes(byte[] body) throws IOException { + terminate(); + applyHeaders(); + exchange.sendResponseHeaders(status, body.length); + if (body.length > 0) { + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + } + } + + @Override + public void text(String body) throws IOException { + pendingHeaders.putIfAbsent(CONTENT_TYPE, "text/plain; charset=UTF-8"); + bytes(body.getBytes(StandardCharsets.UTF_8)); + } + + @Override + public void json(Object body) throws IOException { + this.body("application/json", body); + } + + @Override + public void body(String mediaType, Object value) throws IOException { + TypeMapper mapper = mappers.get(mediaType.toLowerCase(java.util.Locale.ROOT)); + if (mapper == null) { + throw new IllegalStateException("No TypeMapper registered for " + mediaType); + } + pendingHeaders.putIfAbsent(CONTENT_TYPE, mediaType); + bytes(mapper.writeTo(value)); + } + + @Override + public OutputStream stream() throws IOException { + terminate(); + applyHeaders(); + exchange.sendResponseHeaders(status, 0); + return exchange.getResponseBody(); + } + + @Override + public OutputStream stream(long length) throws IOException { + if (length < 0) { + throw new IllegalArgumentException("length must be non-negative"); + } + terminate(); + applyHeaders(); + exchange.sendResponseHeaders(status, length); + return exchange.getResponseBody(); + } + + private void terminate() { + checkNotTerminated(); + terminated = true; + } + + private void checkNotTerminated() { + if (terminated) { + throw new IllegalStateException("Response already sent"); + } + } + + private void applyHeaders() { + pendingHeaders.forEach(exchange.getResponseHeaders()::add); + } +} +``` + +- [ ] **Step 5: Create the new `Request` class.** + +Create `src/main/java/com/retailsvc/http/Request.java`: + +```java +package com.retailsvc.http; + +import com.retailsvc.http.internal.DefaultResponseBuilder; +import com.sun.net.httpserver.HttpExchange; +import java.util.Map; + +/** + * The per-request handle passed to {@link RequestHandler}. Carries the parsed body, path + * parameters, operation ID, and a fluent {@link ResponseBuilder} for writing the response. + */ +public final class Request { + + private final HttpExchange exchange; + private final byte[] body; + private final Object parsed; + private final String operationId; + private final Map pathParameters; + private final Map bodyMappers; + + public Request( + HttpExchange exchange, + byte[] body, + Object parsed, + String operationId, + Map pathParameters, + Map bodyMappers) { + this.exchange = exchange; + this.body = body; + this.parsed = parsed; + this.operationId = operationId; + this.pathParameters = pathParameters; + this.bodyMappers = bodyMappers; + } + + public byte[] bytes() { + return body; + } + + public Object parsed() { + return parsed; + } + + public String operationId() { + return operationId; + } + + public Map pathParams() { + return pathParameters; + } + + public String header(String name) { + return exchange.getRequestHeaders().getFirst(name); + } + + public ResponseBuilder respond(int status) { + return new DefaultResponseBuilder(exchange, status, bodyMappers); + } +} +``` + +- [ ] **Step 6: Compile-check (no consumers yet, the new types coexist with `LegacyRequestAccess`).** + +Run: `mvn test-compile -q` +Expected: BUILD SUCCESS. + +- [ ] **Step 7: Commit.** + +```bash +git add src/main/java/com/retailsvc/http/Request.java \ + src/main/java/com/retailsvc/http/RequestHandler.java \ + src/main/java/com/retailsvc/http/ResponseBuilder.java \ + src/main/java/com/retailsvc/http/internal/DefaultResponseBuilder.java \ + src/test/java/com/retailsvc/http/RequestResponseGatewayTest.java +git commit -m "feat: Add Request, ResponseBuilder, RequestHandler types" +``` + +--- + +## Task 9: Switch builder, filter, and dispatcher to `RequestHandler`/`Request` + +The handler-API cutover: `Builder.handlers(...)` takes `Map`. The filter builds a `Request` and binds it to an internal `ScopedValue`. The dispatcher reads from that scope and calls `RequestHandler`. + +**Files:** +- Modify: `src/main/java/com/retailsvc/http/OpenApiServer.java` (Builder + constructor + extras typing) +- Modify: `src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java` +- Modify: `src/main/java/com/retailsvc/http/internal/DispatchHandler.java` +- Modify: every test handler in `src/test/` +- Modify: `src/test/java/com/retailsvc/http/start/ServerLauncher.java` and any sibling launchers/handlers + +- [ ] **Step 1: Change `DispatchHandler` to take `Map` and read from a new internal `ScopedValue`.** + +Replace `src/main/java/com/retailsvc/http/internal/DispatchHandler.java`: + +```java +package com.retailsvc.http.internal; + +import com.retailsvc.http.MissingOperationHandlerException; +import com.retailsvc.http.Request; +import com.retailsvc.http.RequestHandler; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import java.io.IOException; +import java.util.Map; + +public final class DispatchHandler implements HttpHandler { + + public static final ScopedValue CURRENT = ScopedValue.newInstance(); + + private final Map handlers; + + public DispatchHandler(Map handlers) { + this.handlers = Map.copyOf(handlers); + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + Request request = CURRENT.get(); + RequestHandler h = handlers.get(request.operationId()); + if (h == null) { + throw new MissingOperationHandlerException(request.operationId()); + } + h.handle(request); + } +} +``` + +- [ ] **Step 2: Update `RequestPreparationFilter` to build a `Request` and bind `DispatchHandler.CURRENT`.** + +In `src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java`: + +- Remove the `runWithRequestContext` and `IORunnable` (replaced below). +- Remove references to `LegacyRequestAccess.CONTEXT` (the legacy scope is no longer bound; we'll delete `LegacyRequestAccess` in Task 10). +- Replace the body of `doFilter` after `validateAndParseBody`: + +```java +Request request = + new Request( + exchange, + body, + parsedBody, + op.operationId(), + match.pathParameters(), + bodyMappers); + +try { + ScopedValue.where(DispatchHandler.CURRENT, request) + .call( + () -> { + chain.doFilter(exchange); + return null; + }); +} catch (IOException | RuntimeException e) { + throw e; +} catch (Exception e) { + throw new IOException(e); +} +``` + +Add the imports: `com.retailsvc.http.Request`, `com.retailsvc.http.TypeMapper` (already), and `com.retailsvc.http.internal.DispatchHandler` (already in same package, so no import). + +- [ ] **Step 3: Update `OpenApiServer` constructor and Builder to pass `Map` to `DispatchHandler`.** + +In `src/main/java/com/retailsvc/http/OpenApiServer.java`: + +- Change the constructor parameter `Map handlers` to `Map handlers`. The `ctx.setHandler(new DispatchHandler(handlers));` call still compiles. +- Change the `Builder` field `Map handlers` to `Map handlers`. Update `Builder.handlers(...)` signature accordingly. +- The `addHandler(String, HttpHandler)` extras stay raw `HttpHandler` — they are not OpenAPI-dispatched. +- Update imports: add `import com.retailsvc.http.RequestHandler;`. + +- [ ] **Step 4: Migrate all test handlers from `HttpHandler` to `RequestHandler`.** + +In `src/test/java/com/retailsvc/http/ServerBaseTest.java`: + +```java +protected OpenApiServer newServer(Map handlers) { /* unchanged otherwise */ } +``` + +Update the `import com.sun.net.httpserver.HttpHandler;` → `import com.retailsvc.http.RequestHandler;`. + +Every test handler in `src/test/` that builds `Map` with lambda `ex -> { ... }` becomes `Map` with lambda `req -> { ... }`. The body of each handler is rewritten: + +| Before (HttpHandler) | After (RequestHandler) | +| --- | --- | +| `LegacyRequestAccess.parsed()` | `req.parsed()` | +| `LegacyRequestAccess.bytes()` | `req.bytes()` | +| `LegacyRequestAccess.operationId()` | `req.operationId()` | +| `LegacyRequestAccess.pathParams()` | `req.pathParams()` | +| `ex.getResponseHeaders().add("Content-Type", "application/json")` + `sendResponseHeaders(200, bytes.length)` + `out.write(bytes)` | `req.respond(200).contentType("application/json").bytes(bytes)` *or* `req.respond(200).json(value)` | +| `ex.sendResponseHeaders(204, -1)` | `req.respond(204).empty()` | + +Touch every test file under `src/test/java/com/retailsvc/http/` that constructs handlers. The compiler will list them; iterate until `mvn test-compile` is green. + +`ExtraHandlersIT.java` uses raw `HttpHandler` for extras — leave that alone (`addHandler(path, HttpHandler)` is unchanged). + +- [ ] **Step 5: Migrate the example launcher.** + +In `src/test/java/com/retailsvc/http/start/ServerLauncher.java` and any sibling `*Handler.java` files in that package: convert handlers from `HttpHandler` to `RequestHandler`. Replace `Request.parsed()` (currently routed through `LegacyRequestAccess`) with `request.parsed()` on the handler parameter. + +- [ ] **Step 6: Run the full test suite (unit + IT).** + +Run: `mvn verify -q` +Expected: BUILD SUCCESS. All gateway tests (`RequestResponseGatewayTest`) now pass. + +- [ ] **Step 7: Commit.** + +```bash +git add -A +git commit -m "feat!: Switch handlers to RequestHandler receiving Request" +``` + +--- + +## Task 10: Delete `LegacyRequestAccess` and `RequestContext` + +With no consumers left, remove the legacy scaffolding. + +**Files:** +- Delete: `src/main/java/com/retailsvc/http/internal/LegacyRequestAccess.java` +- Delete: `src/main/java/com/retailsvc/http/internal/RequestContext.java` + +- [ ] **Step 1: Confirm no remaining references.** + +Run: `grep -rn "LegacyRequestAccess\|RequestContext" src/main src/test` +Expected: no matches. If any remain, migrate them to the new `Request` API. + +- [ ] **Step 2: Delete the files.** + +```bash +git rm src/main/java/com/retailsvc/http/internal/LegacyRequestAccess.java src/main/java/com/retailsvc/http/internal/RequestContext.java +``` + +- [ ] **Step 3: Run tests.** + +Run: `mvn verify -q` +Expected: BUILD SUCCESS. + +- [ ] **Step 4: Commit.** + +```bash +git commit -m "refactor: Remove LegacyRequestAccess and RequestContext" +``` + +--- + +## Task 11: README and final pass + +Document the new API surface and the Gson fallback / write caveat. + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Read the current README to find the JsonMapper / handler sections.** + +Run: `grep -n "JsonMapper\|jsonMapper\|HttpHandler\|Request\\.parsed" README.md` + +- [ ] **Step 2: Edit each section.** + +For each match: + +- Replace `jsonMapper(body -> gson.fromJson(...))` examples with either: + - the minimal "Gson is on classpath — fallback handles it" example, or + - the explicit `bodyMapper("application/json", customMapper)` example. +- Replace `HttpHandler` handler examples with `RequestHandler` lambdas that use `request.respond(200).json(...)`. +- Add a short "JSON mapping" subsection documenting: + - the Gson fallback (auto-registered when Gson is on the classpath, integer-preserving, JSR-310 write as ISO-8601); + - the write caveat: for non-ISO date formats, custom naming, or custom types, register your own `TypeMapper` for `application/json`. + +- [ ] **Step 3: Verify Markdown still passes the editorconfig hook.** + +Run: `pre-commit run --files README.md` +Expected: all hooks pass. + +- [ ] **Step 4: Run the full suite one more time.** + +Run: `mvn verify -q` +Expected: BUILD SUCCESS. + +- [ ] **Step 5: Commit.** + +```bash +git add README.md +git commit -m "docs: Update README for TypeMapper and RequestHandler" +``` + +--- + +## Final verification + +- [ ] Run `mvn verify` end-to-end one final time and confirm `target/site/jacoco/` shows coverage for `TypeMapper`, `GsonJsonMapper`, `Request`, `DefaultResponseBuilder`, and `RequestHandler` paths. +- [ ] Confirm the changes against the spec sections: TypeMapper, Built-in defaults, Optional Gson fallback (incl. integer + JSR-310 adapters), Request (read API + response gateway), RequestHandler, Builder shape, Filter→dispatcher handoff, Breaking changes, Testing. +- [ ] `grep -rn "JsonMapper\|RequestContext\|LegacyRequestAccess" src/` returns nothing. From ad510672f35226177df02ef4962233d03bbb3188 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 13:34:30 +0200 Subject: [PATCH 07/50] docs: Fix test method names to camelCase in plan --- .../2026-05-13-type-mapper-request-handler.md | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/superpowers/plans/2026-05-13-type-mapper-request-handler.md b/docs/superpowers/plans/2026-05-13-type-mapper-request-handler.md index 8645d6f..47eb38e 100644 --- a/docs/superpowers/plans/2026-05-13-type-mapper-request-handler.md +++ b/docs/superpowers/plans/2026-05-13-type-mapper-request-handler.md @@ -171,7 +171,7 @@ import org.junit.jupiter.api.Test; class TypeMapperShapeTest { @Test - void roundTrips_viaInlineImplementation() { + void roundTripsViaInlineImplementation() { TypeMapper identity = new TypeMapper() { @Override @@ -279,7 +279,7 @@ class FormTypeMapperTest { } @Test - void writeTo_isUnsupported() { + void writeToIsUnsupported() { assertThatThrownBy(() -> mapper.writeTo(Map.of("k", "v"))) .isInstanceOf(UnsupportedOperationException.class); } @@ -342,19 +342,19 @@ class TextTypeMapperTest { private final TextTypeMapper mapper = new TextTypeMapper(); @Test - void reads_utf8_default() { + void readsUtf8ByDefault() { byte[] body = "hello".getBytes(StandardCharsets.UTF_8); assertThat(mapper.readFrom(body, "text/plain")).isEqualTo("hello"); } @Test - void reads_explicitCharset() { + void readsExplicitCharset() { byte[] body = "räksmörgås".getBytes(StandardCharsets.ISO_8859_1); assertThat(mapper.readFrom(body, "text/plain; charset=ISO-8859-1")).isEqualTo("räksmörgås"); } @Test - void writes_stringValueAsUtf8() { + void writesStringValueAsUtf8() { assertThat(mapper.writeTo("ok")).isEqualTo("ok".getBytes(StandardCharsets.UTF_8)); assertThat(mapper.writeTo(42)).isEqualTo("42".getBytes(StandardCharsets.UTF_8)); assertThat(mapper.writeTo(null)).isEqualTo("null".getBytes(StandardCharsets.UTF_8)); @@ -485,7 +485,7 @@ class GsonJsonMapperTest { private final GsonJsonMapper mapper = new GsonJsonMapper(); @Test - void read_preservesIntegersAsLong() { + void readPreservesIntegersAsLong() { @SuppressWarnings("unchecked") Map parsed = (Map) mapper.readFrom(bytes("{\"n\":42}"), "application/json"); @@ -493,7 +493,7 @@ class GsonJsonMapperTest { } @Test - void read_keepsFractionalAsDouble() { + void readKeepsFractionalAsDouble() { @SuppressWarnings("unchecked") Map parsed = (Map) mapper.readFrom(bytes("{\"n\":1.5}"), "application/json"); @@ -501,7 +501,7 @@ class GsonJsonMapperTest { } @Test - void read_basicTypes() { + void readBasicTypes() { @SuppressWarnings("unchecked") Map parsed = (Map) @@ -515,34 +515,34 @@ class GsonJsonMapperTest { } @Test - void write_mapAndList() { + void writesMapAndList() { byte[] out = mapper.writeTo(Map.of("k", List.of(1L, 2L))); assertThat(new String(out, StandardCharsets.UTF_8)).isEqualTo("{\"k\":[1,2]}"); } @Test - void write_instantAsIso8601() { + void writesInstantAsIso8601() { Instant t = Instant.parse("2026-05-13T10:00:00Z"); assertThat(new String(mapper.writeTo(Map.of("ts", t)), StandardCharsets.UTF_8)) .isEqualTo("{\"ts\":\"2026-05-13T10:00:00Z\"}"); } @Test - void write_offsetDateTimeAsIso8601() { + void writesOffsetDateTimeAsIso8601() { OffsetDateTime t = OffsetDateTime.of(2026, 5, 13, 10, 0, 0, 0, ZoneOffset.UTC); assertThat(new String(mapper.writeTo(Map.of("ts", t)), StandardCharsets.UTF_8)) .isEqualTo("{\"ts\":\"2026-05-13T10:00Z\"}"); } @Test - void write_zonedDateTimeAsIso8601() { + void writesZonedDateTimeAsIso8601() { ZonedDateTime t = ZonedDateTime.of(2026, 5, 13, 10, 0, 0, 0, ZoneOffset.UTC); assertThat(new String(mapper.writeTo(Map.of("ts", t)), StandardCharsets.UTF_8)) .contains("2026-05-13T10:00Z"); } @Test - void write_localDateTimeAsIso8601() { + void writesLocalDateTimeAsIso8601() { assertThat( new String( mapper.writeTo(Map.of("ts", LocalDateTime.of(2026, 5, 13, 10, 0))), @@ -551,7 +551,7 @@ class GsonJsonMapperTest { } @Test - void write_localDateAsIso8601() { + void writesLocalDateAsIso8601() { assertThat( new String( mapper.writeTo(Map.of("d", LocalDate.of(2026, 5, 13))), StandardCharsets.UTF_8)) @@ -559,7 +559,7 @@ class GsonJsonMapperTest { } @Test - void write_localTimeAsIso8601() { + void writesLocalTimeAsIso8601() { assertThat( new String(mapper.writeTo(Map.of("t", LocalTime.of(10, 0))), StandardCharsets.UTF_8)) .isEqualTo("{\"t\":\"10:00\"}"); @@ -807,7 +807,7 @@ import org.junit.jupiter.api.Test; class TypeMapperRegistrationTest extends ServerBaseTest { @Test - void gsonFallback_isAutoRegisteredWhenNoJsonMapperConfigured() throws Exception { + void gsonFallbackIsAutoRegisteredWhenNoJsonMapperConfigured() throws Exception { HttpHandler echo = ex -> { Object parsed = Request.parsed(); @@ -841,7 +841,7 @@ class TypeMapperRegistrationTest extends ServerBaseTest { } @Test - void userSuppliedMapper_overridesDefault() throws Exception { + void userSuppliedMapperOverridesDefault() throws Exception { TypeMapper marker = new TypeMapper() { @Override public Object readFrom(byte[] b, String h) { return Map.of("from", "custom"); } @Override public byte[] writeTo(Object v) { return "ignored".getBytes(StandardCharsets.UTF_8); } @@ -862,7 +862,7 @@ class TypeMapperRegistrationTest extends ServerBaseTest { } @Test - void bodyMapper_rejectsNullArgs() { + void bodyMapperRejectsNullArgs() { var b = OpenApiServer.builder(); assertThatThrownBy(() -> b.bodyMapper(null, new GsonOnlyMapper())) .isInstanceOf(NullPointerException.class); @@ -1298,7 +1298,7 @@ import org.junit.jupiter.api.Test; class RequestResponseGatewayTest extends ServerBaseTest { @Test - void respondJson_writesBodyAndContentType() throws Exception { + void respondJsonWritesBodyAndContentType() throws Exception { RequestHandler echo = req -> req.respond(200).json(Map.of("op", req.operationId())); server = @@ -1326,7 +1326,7 @@ class RequestResponseGatewayTest extends ServerBaseTest { } @Test - void respondEmpty_uses204Style() throws Exception { + void respondEmptyUses204Style() throws Exception { RequestHandler ok = req -> req.respond(204).empty(); server = OpenApiServer.builder() @@ -1347,7 +1347,7 @@ class RequestResponseGatewayTest extends ServerBaseTest { } @Test - void respondStream_chunkedEncoding() throws Exception { + void respondStreamUsesChunkedEncoding() throws Exception { RequestHandler streamer = req -> { try (var out = req.respond(200).contentType("text/plain").stream()) { From 2a6cd23edeea2fc80961c605d25fa5769b197e33 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 13:50:41 +0200 Subject: [PATCH 08/50] refactor: Extract form-body schema coercion from FormUrlEncodedParser --- .../http/internal/FormBodyCoercion.java | 45 +++++++++++++++++++ .../http/internal/FormUrlEncodedParser.java | 32 ------------- .../internal/RequestPreparationFilter.java | 2 +- .../internal/FormUrlEncodedParserTest.java | 15 ++++--- 4 files changed, 56 insertions(+), 38 deletions(-) create mode 100644 src/main/java/com/retailsvc/http/internal/FormBodyCoercion.java diff --git a/src/main/java/com/retailsvc/http/internal/FormBodyCoercion.java b/src/main/java/com/retailsvc/http/internal/FormBodyCoercion.java new file mode 100644 index 0000000..f239bbd --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/FormBodyCoercion.java @@ -0,0 +1,45 @@ +package com.retailsvc.http.internal; + +import com.retailsvc.http.spec.schema.ArraySchema; +import com.retailsvc.http.spec.schema.ObjectSchema; +import com.retailsvc.http.spec.schema.Schema; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Coerces string-typed values produced by {@link FormUrlEncodedParser} into the Java types + * described by the body schema (numbers, booleans, arrays). Called by {@link + * RequestPreparationFilter} after parsing, before validation. + */ +final class FormBodyCoercion { + + private FormBodyCoercion() {} + + static Map coerce(Map parsed, Schema schema) { + if (!(schema instanceof ObjectSchema obj)) { + return parsed; + } + Map properties = obj.properties(); + for (Map.Entry e : parsed.entrySet()) { + Schema propSchema = properties.get(e.getKey()); + if (propSchema == null) { + continue; + } + String pointer = "/" + e.getKey(); + Object value = e.getValue(); + if (propSchema instanceof ArraySchema arr && value instanceof List list) { + List coerced = new ArrayList<>(list.size()); + for (int i = 0; i < list.size(); i++) { + coerced.add(ValueCoercion.coerce((String) list.get(i), arr.items(), pointer + "/" + i)); + } + e.setValue(coerced); + } else if (propSchema instanceof ArraySchema arr && value instanceof String s) { + e.setValue(List.of(ValueCoercion.coerce(s, arr.items(), pointer + "/0"))); + } else if (value instanceof String s) { + e.setValue(ValueCoercion.coerce(s, propSchema, pointer)); + } + } + return parsed; + } +} diff --git a/src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java b/src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java index 1bb65a0..f3811a7 100644 --- a/src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java +++ b/src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java @@ -1,9 +1,6 @@ package com.retailsvc.http.internal; import com.retailsvc.http.ValidationException; -import com.retailsvc.http.spec.schema.ArraySchema; -import com.retailsvc.http.spec.schema.ObjectSchema; -import com.retailsvc.http.spec.schema.Schema; import com.retailsvc.http.validate.ValidationError; import java.net.URLDecoder; import java.nio.charset.Charset; @@ -71,35 +68,6 @@ private static void addEntry(Map out, String key, String value) }); } - /** Returns the parsed map after coercing field values against the given body schema. */ - public Map parseAndCoerce(byte[] body, String contentTypeHeader, Schema schema) { - Map parsed = parse(body, contentTypeHeader); - if (!(schema instanceof ObjectSchema obj)) { - return parsed; - } - Map properties = obj.properties(); - for (Map.Entry e : parsed.entrySet()) { - Schema propSchema = properties.get(e.getKey()); - if (propSchema == null) { - continue; - } - String pointer = "/" + e.getKey(); - Object value = e.getValue(); - if (propSchema instanceof ArraySchema arr && value instanceof List list) { - List coerced = new ArrayList<>(list.size()); - for (int i = 0; i < list.size(); i++) { - coerced.add(ValueCoercion.coerce((String) list.get(i), arr.items(), pointer + "/" + i)); - } - e.setValue(coerced); - } else if (propSchema instanceof ArraySchema arr && value instanceof String s) { - e.setValue(List.of(ValueCoercion.coerce(s, arr.items(), pointer + "/0"))); - } else if (value instanceof String s) { - e.setValue(ValueCoercion.coerce(s, propSchema, pointer)); - } - } - return parsed; - } - private static Charset resolveCharset(String header) { return ContentTypeHeader.parameter(header, "charset") .map(FormUrlEncodedParser::safeCharset) diff --git a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java index 58b5b3a..0c1319f 100644 --- a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java +++ b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java @@ -153,7 +153,7 @@ private Object validateAndParseBody(HttpExchange exchange, Operation op, byte[] Object parsed = switch (mediaType) { case "application/x-www-form-urlencoded" -> - formParser.parseAndCoerce(body, header, mt.schema()); + FormBodyCoercion.coerce(formParser.parse(body, header), mt.schema()); case "text/plain" -> textParser.parse(body, header); default -> jsonMapper.mapFrom(body); }; diff --git a/src/test/java/com/retailsvc/http/internal/FormUrlEncodedParserTest.java b/src/test/java/com/retailsvc/http/internal/FormUrlEncodedParserTest.java index 5db5173..a286a67 100644 --- a/src/test/java/com/retailsvc/http/internal/FormUrlEncodedParserTest.java +++ b/src/test/java/com/retailsvc/http/internal/FormUrlEncodedParserTest.java @@ -84,7 +84,8 @@ void coercesIntegerProperty() { ObjectSchema bodySchema = anObjectSchema(Map.of("age", intSchema)); Map out = - parser.parseAndCoerce("age=30".getBytes(StandardCharsets.UTF_8), null, bodySchema); + FormBodyCoercion.coerce( + parser.parse("age=30".getBytes(StandardCharsets.UTF_8), null), bodySchema); assertThat(out).containsExactly(Map.entry("age", 30L)); } @@ -96,7 +97,8 @@ void coercesArrayOfIntegersProperty() { ObjectSchema bodySchema = anObjectSchema(Map.of("ids", arrSchema)); Map out = - parser.parseAndCoerce("ids=1&ids=2".getBytes(StandardCharsets.UTF_8), null, bodySchema); + FormBodyCoercion.coerce( + parser.parse("ids=1&ids=2".getBytes(StandardCharsets.UTF_8), null), bodySchema); assertThat(out).containsExactly(Map.entry("ids", List.of(1L, 2L))); } @@ -108,7 +110,8 @@ void coercionFailureThrowsValidationExceptionAtPropertyPointer() { org.assertj.core.api.Assertions.assertThatThrownBy( () -> - parser.parseAndCoerce("age=abc".getBytes(StandardCharsets.UTF_8), null, bodySchema)) + FormBodyCoercion.coerce( + parser.parse("age=abc".getBytes(StandardCharsets.UTF_8), null), bodySchema)) .isInstanceOf(com.retailsvc.http.ValidationException.class) .extracting("error.pointer", "error.keyword") .containsExactly("/age", "type"); @@ -119,7 +122,8 @@ void unknownPropertyPassesThroughUnchanged() { ObjectSchema bodySchema = anObjectSchema(Map.of()); Map out = - parser.parseAndCoerce("anything=v".getBytes(StandardCharsets.UTF_8), null, bodySchema); + FormBodyCoercion.coerce( + parser.parse("anything=v".getBytes(StandardCharsets.UTF_8), null), bodySchema); assertThat(out).containsExactly(Map.entry("anything", "v")); } @@ -129,7 +133,8 @@ void nonObjectSchemaReturnsRawMap() { StringSchema strSchema = aStringSchema(); Map out = - parser.parseAndCoerce("a=1".getBytes(StandardCharsets.UTF_8), null, strSchema); + FormBodyCoercion.coerce( + parser.parse("a=1".getBytes(StandardCharsets.UTF_8), null), strSchema); assertThat(out).containsExactly(Map.entry("a", "1")); } From f51a85b06a5df87413b1724b74900174e4606528 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 13:56:31 +0200 Subject: [PATCH 09/50] feat: Add TypeMapper interface --- .../java/com/retailsvc/http/TypeMapper.java | 21 ++++++++++++++ .../retailsvc/http/TypeMapperShapeTest.java | 29 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 src/main/java/com/retailsvc/http/TypeMapper.java create mode 100644 src/test/java/com/retailsvc/http/TypeMapperShapeTest.java diff --git a/src/main/java/com/retailsvc/http/TypeMapper.java b/src/main/java/com/retailsvc/http/TypeMapper.java new file mode 100644 index 0000000..edf7e61 --- /dev/null +++ b/src/main/java/com/retailsvc/http/TypeMapper.java @@ -0,0 +1,21 @@ +package com.retailsvc.http; + +/** + * Reads and writes request/response bodies for a specific media type. Registered on {@link + * OpenApiServer.Builder#bodyMapper(String, TypeMapper)} keyed by media type. The library ships + * built-in mappers for {@code application/x-www-form-urlencoded} and {@code text/plain}; an {@code + * application/json} mapper must be supplied by the caller or auto-detected via Gson on the + * classpath. + */ +public interface TypeMapper { + + /** + * @param body raw request body bytes + * @param contentTypeHeader the full raw {@code Content-Type} header, used for charset and other + * parameters (the JSON mapper ignores it) + */ + Object readFrom(byte[] body, String contentTypeHeader); + + /** Serializes {@code value} to bytes suitable for writing as the response body. */ + byte[] writeTo(Object value); +} diff --git a/src/test/java/com/retailsvc/http/TypeMapperShapeTest.java b/src/test/java/com/retailsvc/http/TypeMapperShapeTest.java new file mode 100644 index 0000000..40967a0 --- /dev/null +++ b/src/test/java/com/retailsvc/http/TypeMapperShapeTest.java @@ -0,0 +1,29 @@ +package com.retailsvc.http; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class TypeMapperShapeTest { + + @Test + void roundTripsViaInlineImplementation() { + TypeMapper identity = + new TypeMapper() { + @Override + public Object readFrom(byte[] body, String contentTypeHeader) { + return new String(body, StandardCharsets.UTF_8); + } + + @Override + public byte[] writeTo(Object value) { + return ((String) value).getBytes(StandardCharsets.UTF_8); + } + }; + + Object read = identity.readFrom("hi".getBytes(StandardCharsets.UTF_8), "text/plain"); + assertThat(read).isEqualTo("hi"); + assertThat(identity.writeTo("hi")).containsExactly('h', 'i'); + } +} From cfbb5564ab39780af913d54c0ca50382f9018fd9 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 14:20:30 +0200 Subject: [PATCH 10/50] feat: Built-in FormTypeMapper and TextTypeMapper --- .../http/internal/FormTypeMapper.java | 24 +++++++++++++++ .../http/internal/TextTypeMapper.java | 24 +++++++++++++++ .../http/internal/FormTypeMapperTest.java | 29 ++++++++++++++++++ .../http/internal/TextTypeMapperTest.java | 30 +++++++++++++++++++ 4 files changed, 107 insertions(+) create mode 100644 src/main/java/com/retailsvc/http/internal/FormTypeMapper.java create mode 100644 src/main/java/com/retailsvc/http/internal/TextTypeMapper.java create mode 100644 src/test/java/com/retailsvc/http/internal/FormTypeMapperTest.java create mode 100644 src/test/java/com/retailsvc/http/internal/TextTypeMapperTest.java diff --git a/src/main/java/com/retailsvc/http/internal/FormTypeMapper.java b/src/main/java/com/retailsvc/http/internal/FormTypeMapper.java new file mode 100644 index 0000000..c5fea5b --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/FormTypeMapper.java @@ -0,0 +1,24 @@ +package com.retailsvc.http.internal; + +import com.retailsvc.http.TypeMapper; + +/** + * Built-in {@link TypeMapper} for {@code application/x-www-form-urlencoded}. Reads delegate to + * {@link FormUrlEncodedParser}. Writes are not supported — form-encoded responses are unusual and + * intentionally left out until a real need surfaces. + */ +public final class FormTypeMapper implements TypeMapper { + + private final FormUrlEncodedParser parser = new FormUrlEncodedParser(); + + @Override + public Object readFrom(byte[] body, String contentTypeHeader) { + return parser.parse(body, contentTypeHeader); + } + + @Override + public byte[] writeTo(Object value) { + throw new UnsupportedOperationException( + "application/x-www-form-urlencoded write is not supported; register a custom TypeMapper"); + } +} diff --git a/src/main/java/com/retailsvc/http/internal/TextTypeMapper.java b/src/main/java/com/retailsvc/http/internal/TextTypeMapper.java new file mode 100644 index 0000000..0aec48b --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/TextTypeMapper.java @@ -0,0 +1,24 @@ +package com.retailsvc.http.internal; + +import com.retailsvc.http.TypeMapper; +import java.nio.charset.StandardCharsets; + +/** + * Built-in {@link TypeMapper} for {@code text/plain}. Reads decode bytes using the charset declared + * on {@code Content-Type} (default UTF-8). Writes return {@code String.valueOf(value)} encoded as + * UTF-8. + */ +public final class TextTypeMapper implements TypeMapper { + + private final TextPlainParser parser = new TextPlainParser(); + + @Override + public Object readFrom(byte[] body, String contentTypeHeader) { + return parser.parse(body, contentTypeHeader); + } + + @Override + public byte[] writeTo(Object value) { + return String.valueOf(value).getBytes(StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/com/retailsvc/http/internal/FormTypeMapperTest.java b/src/test/java/com/retailsvc/http/internal/FormTypeMapperTest.java new file mode 100644 index 0000000..eac7250 --- /dev/null +++ b/src/test/java/com/retailsvc/http/internal/FormTypeMapperTest.java @@ -0,0 +1,29 @@ +package com.retailsvc.http.internal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class FormTypeMapperTest { + + private final FormTypeMapper mapper = new FormTypeMapper(); + + @Test + void readsKeyValuePairs() { + byte[] body = "name=Alice&color=blue".getBytes(StandardCharsets.UTF_8); + Object parsed = mapper.readFrom(body, "application/x-www-form-urlencoded"); + assertThat(parsed).isInstanceOf(Map.class); + @SuppressWarnings("unchecked") + Map m = (Map) parsed; + assertThat(m).containsEntry("name", "Alice").containsEntry("color", "blue"); + } + + @Test + void writeToIsUnsupported() { + assertThatThrownBy(() -> mapper.writeTo(Map.of("k", "v"))) + .isInstanceOf(UnsupportedOperationException.class); + } +} diff --git a/src/test/java/com/retailsvc/http/internal/TextTypeMapperTest.java b/src/test/java/com/retailsvc/http/internal/TextTypeMapperTest.java new file mode 100644 index 0000000..5988100 --- /dev/null +++ b/src/test/java/com/retailsvc/http/internal/TextTypeMapperTest.java @@ -0,0 +1,30 @@ +package com.retailsvc.http.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class TextTypeMapperTest { + + private final TextTypeMapper mapper = new TextTypeMapper(); + + @Test + void readsUtf8ByDefault() { + byte[] body = "hello".getBytes(StandardCharsets.UTF_8); + assertThat(mapper.readFrom(body, "text/plain")).isEqualTo("hello"); + } + + @Test + void readsExplicitCharset() { + byte[] body = "räksmörgås".getBytes(StandardCharsets.ISO_8859_1); + assertThat(mapper.readFrom(body, "text/plain; charset=ISO-8859-1")).isEqualTo("räksmörgås"); + } + + @Test + void writesStringValueAsUtf8() { + assertThat(mapper.writeTo("ok")).isEqualTo("ok".getBytes(StandardCharsets.UTF_8)); + assertThat(mapper.writeTo(42)).isEqualTo("42".getBytes(StandardCharsets.UTF_8)); + assertThat(mapper.writeTo(null)).isEqualTo("null".getBytes(StandardCharsets.UTF_8)); + } +} From e01ef104d556ddad951f2827e424044103df7db4 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 14:40:16 +0200 Subject: [PATCH 11/50] build: Make Gson an optional compile dependency --- pom.xml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pom.xml b/pom.xml index a1db729..b261711 100644 --- a/pom.xml +++ b/pom.xml @@ -38,6 +38,12 @@ + + com.google.code.gson + gson + 2.14.0 + true + org.yaml snakeyaml @@ -57,12 +63,6 @@ 1.5.32 test - - com.google.code.gson - gson - 2.14.0 - test - org.assertj assertj-core From 1f0de238f9d8e68fb755fdc288c5fd717584e39a Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 14:45:03 +0200 Subject: [PATCH 12/50] feat: GsonJsonMapper with integer-preserving and JSR-310 adapters --- .../http/internal/gson/GsonJsonMapper.java | 123 ++++++++++++++++++ .../internal/gson/GsonJsonMapperTest.java | 103 +++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java create mode 100644 src/test/java/com/retailsvc/http/internal/gson/GsonJsonMapperTest.java diff --git a/src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java b/src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java new file mode 100644 index 0000000..c4c1f73 --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java @@ -0,0 +1,123 @@ +package com.retailsvc.http.internal.gson; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import com.retailsvc.http.TypeMapper; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Built-in {@link TypeMapper} for {@code application/json} backed by Gson. Auto-registered by + * {@link com.retailsvc.http.OpenApiServer.Builder} when Gson is on the classpath and no + * user-supplied JSON mapper has been registered. + * + *

JSON numbers without a decimal point or exponent are returned as {@code Long}; fractional + * numbers are returned as {@code Double}. JSR-310 types ({@code Instant}, {@code OffsetDateTime}, + * {@code ZonedDateTime}, {@code LocalDateTime}, {@code LocalDate}, {@code LocalTime}) are written + * as their ISO-8601 string form. + */ +public final class GsonJsonMapper implements TypeMapper { + + private final Gson gson; + + public GsonJsonMapper() { + this.gson = + new GsonBuilder() + .registerTypeAdapter(Instant.class, isoStringWriter(Instant::toString)) + .registerTypeAdapter(OffsetDateTime.class, isoStringWriter(OffsetDateTime::toString)) + .registerTypeAdapter(ZonedDateTime.class, isoStringWriter(ZonedDateTime::toString)) + .registerTypeAdapter(LocalDateTime.class, isoStringWriter(LocalDateTime::toString)) + .registerTypeAdapter(LocalDate.class, isoStringWriter(LocalDate::toString)) + .registerTypeAdapter(LocalTime.class, isoStringWriter(LocalTime::toString)) + .create(); + } + + @Override + public Object readFrom(byte[] body, String contentTypeHeader) { + JsonElement element = JsonParser.parseString(new String(body, StandardCharsets.UTF_8)); + return toJavaObject(element); + } + + @Override + public byte[] writeTo(Object value) { + return gson.toJson(value).getBytes(StandardCharsets.UTF_8); + } + + /** + * Recursively converts a {@link JsonElement} tree to plain Java objects, preserving integers as + * {@code Long} and fractional numbers as {@code Double}. + */ + private static Object toJavaObject(JsonElement element) { + if (element == null || element instanceof JsonNull) { + return null; + } else if (element instanceof JsonObject obj) { + Map map = new LinkedHashMap<>(); + for (Map.Entry entry : obj.entrySet()) { + map.put(entry.getKey(), toJavaObject(entry.getValue())); + } + return map; + } else if (element instanceof JsonArray arr) { + List list = new ArrayList<>(arr.size()); + for (JsonElement item : arr) { + list.add(toJavaObject(item)); + } + return list; + } else if (element instanceof JsonPrimitive prim) { + if (prim.isBoolean()) { + return prim.getAsBoolean(); + } else if (prim.isString()) { + return prim.getAsString(); + } else { + // Number + String raw = prim.getAsString(); + if (raw.indexOf('.') < 0 && raw.indexOf('e') < 0 && raw.indexOf('E') < 0) { + try { + return Long.parseLong(raw); + } catch (NumberFormatException _) { + // falls through to Double for out-of-range integers + } + } + return Double.parseDouble(raw); + } + } + throw new IllegalStateException("Unexpected JsonElement type: " + element.getClass()); + } + + private static TypeAdapter isoStringWriter(java.util.function.Function toIso) { + return new TypeAdapter() { + @Override + public void write(JsonWriter out, T value) throws IOException { + if (value == null) { + out.nullValue(); + } else { + out.value(toIso.apply(value)); + } + } + + @Override + public T read(JsonReader in) { + throw new UnsupportedOperationException( + "GsonJsonMapper does not parse JSR-310 types; values arrive as String"); + } + }; + } +} diff --git a/src/test/java/com/retailsvc/http/internal/gson/GsonJsonMapperTest.java b/src/test/java/com/retailsvc/http/internal/gson/GsonJsonMapperTest.java new file mode 100644 index 0000000..7291306 --- /dev/null +++ b/src/test/java/com/retailsvc/http/internal/gson/GsonJsonMapperTest.java @@ -0,0 +1,103 @@ +package com.retailsvc.http.internal.gson; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class GsonJsonMapperTest { + + private final GsonJsonMapper mapper = new GsonJsonMapper(); + + @Test + void readPreservesIntegersAsLong() { + @SuppressWarnings("unchecked") + Map parsed = + (Map) mapper.readFrom(bytes("{\"n\":42}"), "application/json"); + assertThat(parsed.get("n")).isEqualTo(42L).isInstanceOf(Long.class); + } + + @Test + void readKeepsFractionalAsDouble() { + @SuppressWarnings("unchecked") + Map parsed = + (Map) mapper.readFrom(bytes("{\"n\":1.5}"), "application/json"); + assertThat(parsed.get("n")).isEqualTo(1.5).isInstanceOf(Double.class); + } + + @Test + void readBasicTypes() { + @SuppressWarnings("unchecked") + Map parsed = + (Map) + mapper.readFrom( + bytes("{\"s\":\"hi\",\"b\":true,\"n\":null,\"a\":[1,2]}"), "application/json"); + assertThat(parsed.get("s")).isEqualTo("hi"); + assertThat(parsed.get("b")).isEqualTo(Boolean.TRUE); + assertThat(parsed.get("n")).isNull(); + assertThat(parsed.get("a")).isEqualTo(List.of(1L, 2L)); + } + + @Test + void writesMapAndList() { + byte[] out = mapper.writeTo(Map.of("k", List.of(1L, 2L))); + assertThat(new String(out, StandardCharsets.UTF_8)).isEqualTo("{\"k\":[1,2]}"); + } + + @Test + void writesInstantAsIso8601() { + Instant t = Instant.parse("2026-05-13T10:00:00Z"); + assertThat(new String(mapper.writeTo(Map.of("ts", t)), StandardCharsets.UTF_8)) + .isEqualTo("{\"ts\":\"2026-05-13T10:00:00Z\"}"); + } + + @Test + void writesOffsetDateTimeAsIso8601() { + OffsetDateTime t = OffsetDateTime.of(2026, 5, 13, 10, 0, 0, 0, ZoneOffset.UTC); + assertThat(new String(mapper.writeTo(Map.of("ts", t)), StandardCharsets.UTF_8)) + .isEqualTo("{\"ts\":\"2026-05-13T10:00Z\"}"); + } + + @Test + void writesZonedDateTimeAsIso8601() { + ZonedDateTime t = ZonedDateTime.of(2026, 5, 13, 10, 0, 0, 0, ZoneOffset.UTC); + assertThat(new String(mapper.writeTo(Map.of("ts", t)), StandardCharsets.UTF_8)) + .contains("2026-05-13T10:00Z"); + } + + @Test + void writesLocalDateTimeAsIso8601() { + assertThat( + new String( + mapper.writeTo(Map.of("ts", LocalDateTime.of(2026, 5, 13, 10, 0))), + StandardCharsets.UTF_8)) + .isEqualTo("{\"ts\":\"2026-05-13T10:00\"}"); + } + + @Test + void writesLocalDateAsIso8601() { + assertThat( + new String( + mapper.writeTo(Map.of("d", LocalDate.of(2026, 5, 13))), StandardCharsets.UTF_8)) + .isEqualTo("{\"d\":\"2026-05-13\"}"); + } + + @Test + void writesLocalTimeAsIso8601() { + assertThat(new String(mapper.writeTo(Map.of("t", LocalTime.of(10, 0))), StandardCharsets.UTF_8)) + .isEqualTo("{\"t\":\"10:00\"}"); + } + + private static byte[] bytes(String s) { + return s.getBytes(StandardCharsets.UTF_8); + } +} From 86149d2d3bcc0a5e9120a2bb163a5e5234196eaf Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 14:51:48 +0200 Subject: [PATCH 13/50] refactor: Use top-level Function import in GsonJsonMapper --- .../java/com/retailsvc/http/internal/gson/GsonJsonMapper.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java b/src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java index c4c1f73..8c17ff1 100644 --- a/src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java +++ b/src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java @@ -24,6 +24,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.function.Function; /** * Built-in {@link TypeMapper} for {@code application/json} backed by Gson. Auto-registered by @@ -102,7 +103,7 @@ private static Object toJavaObject(JsonElement element) { throw new IllegalStateException("Unexpected JsonElement type: " + element.getClass()); } - private static TypeAdapter isoStringWriter(java.util.function.Function toIso) { + private static TypeAdapter isoStringWriter(Function toIso) { return new TypeAdapter() { @Override public void write(JsonWriter out, T value) throws IOException { From 8e8a70b3a4dfb80fabcfd9e742285391fd5e424a Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 15:27:07 +0200 Subject: [PATCH 14/50] feat!: Replace JsonMapper with bodyMapper(mediaType, TypeMapper) + Gson fallback --- .../java/com/retailsvc/http/JsonMapper.java | 6 - .../com/retailsvc/http/OpenApiServer.java | 91 ++++++++------- .../internal/RequestPreparationFilter.java | 29 ++--- .../com/retailsvc/http/ExtraHandlersIT.java | 19 +--- .../com/retailsvc/http/JsonMapperTest.java | 13 --- .../http/OpenApiServerBuilderTest.java | 62 +++++----- .../com/retailsvc/http/OpenApiServerTest.java | 29 ++--- .../com/retailsvc/http/ServerBaseTest.java | 7 +- .../http/TypeMapperRegistrationTest.java | 106 ++++++++++++++++++ .../RequestPreparationFilterTest.java | 19 +++- .../retailsvc/http/start/ServerLauncher.java | 12 +- 11 files changed, 232 insertions(+), 161 deletions(-) delete mode 100644 src/main/java/com/retailsvc/http/JsonMapper.java delete mode 100644 src/test/java/com/retailsvc/http/JsonMapperTest.java create mode 100644 src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java diff --git a/src/main/java/com/retailsvc/http/JsonMapper.java b/src/main/java/com/retailsvc/http/JsonMapper.java deleted file mode 100644 index d67489b..0000000 --- a/src/main/java/com/retailsvc/http/JsonMapper.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.retailsvc.http; - -@FunctionalInterface -public interface JsonMapper { - Object mapFrom(byte[] body); -} diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index 0192075..b9d15dc 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -6,8 +6,10 @@ import com.retailsvc.http.internal.DispatchHandler; import com.retailsvc.http.internal.ExceptionFilter; +import com.retailsvc.http.internal.FormTypeMapper; import com.retailsvc.http.internal.RequestPreparationFilter; import com.retailsvc.http.internal.Router; +import com.retailsvc.http.internal.TextTypeMapper; import com.retailsvc.http.spec.Spec; import com.retailsvc.http.validate.DefaultValidator; import com.sun.net.httpserver.HttpContext; @@ -30,47 +32,16 @@ public class OpenApiServer implements AutoCloseable { private static final Logger LOG = LoggerFactory.getLogger(OpenApiServer.class); private static final int DEFAULT_PORT = 8080; + private static final String JSON = "application/json"; + private static final String GSON_CLASS = "com.google.gson.Gson"; + private static final String GSON_MAPPER_CLASS = "com.retailsvc.http.internal.gson.GsonJsonMapper"; private final HttpServer httpServer; private final int shutdownTimeoutSeconds; - /** - * @param spec The parsed {@link Spec} - * @param jsonMapper Body deserializer - * @param handlers Mappings between operationId and {@link HttpHandler} - * @param exceptionHandler Error handler receiving exceptions thrown from a handler - * @throws IOException If an error occurs during server start - */ - public OpenApiServer( - Spec spec, - JsonMapper jsonMapper, - Map handlers, - ExceptionHandler exceptionHandler) - throws IOException { - this(spec, jsonMapper, handlers, exceptionHandler, DEFAULT_PORT, Map.of(), 0); - } - - /** - * @param spec The parsed {@link Spec} - * @param jsonMapper Body deserializer - * @param handlers Mappings between operationId and {@link HttpHandler} - * @param exceptionHandler Error handler receiving exceptions thrown from a handler - * @param port The server port to use - * @throws IOException If an error occurs during server start - */ - public OpenApiServer( - Spec spec, - JsonMapper jsonMapper, - Map handlers, - ExceptionHandler exceptionHandler, - int port) - throws IOException { - this(spec, jsonMapper, handlers, exceptionHandler, port, Map.of(), 0); - } - OpenApiServer( Spec spec, - JsonMapper jsonMapper, + Map bodyMappers, Map handlers, ExceptionHandler exceptionHandler, int port, @@ -79,7 +50,7 @@ public OpenApiServer( throws IOException { requireNonNull(spec, "Spec must not be null"); - requireNonNull(jsonMapper, "JsonMapper must not be null"); + requireNonNull(bodyMappers, "bodyMappers must not be null"); requireNonNull(handlers, "handlers must not be null"); if (exceptionHandler == null) { LOG.warn("No ExceptionHandler set, using default"); @@ -95,7 +66,7 @@ public OpenApiServer( HttpContext ctx = httpServer.createContext(Optional.ofNullable(spec.basePath()).orElse("/")); ctx.getFilters().add(new ExceptionFilter(exceptionHandler)); - ctx.getFilters().add(new RequestPreparationFilter(spec, router, validator, jsonMapper)); + ctx.getFilters().add(new RequestPreparationFilter(spec, router, validator, bodyMappers)); ctx.setHandler(new DispatchHandler(handlers)); for (Map.Entry e : extras.entrySet()) { @@ -144,7 +115,7 @@ public static Builder builder() { public static final class Builder { private Spec spec; - private JsonMapper jsonMapper; + private final LinkedHashMap bodyMappers = new LinkedHashMap<>(); private Map handlers; private ExceptionHandler exceptionHandler; private int port = DEFAULT_PORT; @@ -158,8 +129,10 @@ public Builder spec(Spec spec) { return this; } - public Builder jsonMapper(JsonMapper jsonMapper) { - this.jsonMapper = jsonMapper; + public Builder bodyMapper(String mediaType, TypeMapper mapper) { + requireNonNull(mediaType, "mediaType must not be null"); + requireNonNull(mapper, "mapper must not be null"); + bodyMappers.put(mediaType.toLowerCase(java.util.Locale.ROOT), mapper); return this; } @@ -204,7 +177,6 @@ public Builder addHandler(String path, HttpHandler handler) { public OpenApiServer build() throws IOException { requireNonNull(spec, "Spec must not be null"); - requireNonNull(jsonMapper, "JsonMapper must not be null"); requireNonNull(handlers, "handlers must not be null"); String basePath = Optional.ofNullable(spec.basePath()).orElse("/"); for (String path : extras.keySet()) { @@ -213,8 +185,43 @@ public OpenApiServer build() throws IOException { "extra handler path " + path + " conflicts with spec basePath " + basePath); } } + Map resolved = resolveBodyMappers(bodyMappers); return new OpenApiServer( - spec, jsonMapper, handlers, exceptionHandler, port, extras, shutdownTimeoutSeconds); + spec, resolved, handlers, exceptionHandler, port, extras, shutdownTimeoutSeconds); + } + + private static Map resolveBodyMappers( + Map userSupplied) { + LinkedHashMap out = new LinkedHashMap<>(); + out.put("application/x-www-form-urlencoded", new FormTypeMapper()); + out.put("text/plain", new TextTypeMapper()); + out.putAll(userSupplied); + if (!out.containsKey(JSON)) { + TypeMapper fallback = tryLoadGsonMapper(); + if (fallback != null) { + out.put(JSON, fallback); + } + } + if (!out.containsKey(JSON)) { + throw new IllegalStateException( + "No TypeMapper registered for application/json and Gson not found on classpath; " + + "register one via Builder.bodyMapper(\"application/json\", ...)"); + } + return out; + } + + private static TypeMapper tryLoadGsonMapper() { + try { + Class.forName(GSON_CLASS, false, OpenApiServer.class.getClassLoader()); + } catch (ClassNotFoundException _) { + return null; + } + try { + Class cls = Class.forName(GSON_MAPPER_CLASS, true, OpenApiServer.class.getClassLoader()); + return (TypeMapper) cls.getDeclaredConstructor().newInstance(); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Failed to load " + GSON_MAPPER_CLASS, e); + } } } } diff --git a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java index 0c1319f..fec087a 100644 --- a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java +++ b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java @@ -1,9 +1,9 @@ package com.retailsvc.http.internal; -import com.retailsvc.http.JsonMapper; import com.retailsvc.http.MethodNotAllowedException; import com.retailsvc.http.NotFoundException; import com.retailsvc.http.Request; +import com.retailsvc.http.TypeMapper; import com.retailsvc.http.ValidationException; import com.retailsvc.http.spec.HttpMethod; import com.retailsvc.http.spec.MediaType; @@ -26,16 +26,14 @@ public final class RequestPreparationFilter extends Filter { private final Spec spec; private final Router router; private final Validator validator; - private final JsonMapper jsonMapper; - private final FormUrlEncodedParser formParser = new FormUrlEncodedParser(); - private final TextPlainParser textParser = new TextPlainParser(); + private final Map bodyMappers; public RequestPreparationFilter( - Spec spec, Router router, Validator validator, JsonMapper jsonMapper) { + Spec spec, Router router, Validator validator, Map bodyMappers) { this.spec = spec; this.router = router; this.validator = validator; - this.jsonMapper = jsonMapper; + this.bodyMappers = Map.copyOf(bodyMappers); } @Override @@ -150,13 +148,18 @@ private Object validateAndParseBody(HttpExchange exchange, Operation op, byte[] new ValidationError( "/body", "content-type", "unsupported content type: " + mediaType, null)); } - Object parsed = - switch (mediaType) { - case "application/x-www-form-urlencoded" -> - FormBodyCoercion.coerce(formParser.parse(body, header), mt.schema()); - case "text/plain" -> textParser.parse(body, header); - default -> jsonMapper.mapFrom(body); - }; + TypeMapper mapper = bodyMappers.get(mediaType); + if (mapper == null) { + throw new ValidationException( + new ValidationError( + "/body", "content-type", "unsupported content type: " + mediaType, null)); + } + Object parsed = mapper.readFrom(body, header); + if (mediaType.equals("application/x-www-form-urlencoded") && parsed instanceof Map map) { + @SuppressWarnings("unchecked") + Map typed = (Map) map; + parsed = FormBodyCoercion.coerce(typed, mt.schema()); + } validator.validate(parsed, mt.schema(), ""); return parsed; } diff --git a/src/test/java/com/retailsvc/http/ExtraHandlersIT.java b/src/test/java/com/retailsvc/http/ExtraHandlersIT.java index 9be1629..0a5b6b4 100644 --- a/src/test/java/com/retailsvc/http/ExtraHandlersIT.java +++ b/src/test/java/com/retailsvc/http/ExtraHandlersIT.java @@ -2,10 +2,7 @@ import static com.retailsvc.http.Handlers.defaultExceptionHandler; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.fail; -import com.sun.net.httpserver.HttpHandler; -import java.io.IOException; import java.net.URI; import java.net.http.HttpRequest; import java.net.http.HttpResponse.BodyHandlers; @@ -19,7 +16,6 @@ void aliveExtraReturns204AndBypassesValidation() throws Exception { try (var s = OpenApiServer.builder() .spec(spec) - .jsonMapper(jsonMapper()) .handlers(Map.of()) .exceptionHandler(defaultExceptionHandler()) .port(0) @@ -44,7 +40,6 @@ void specHandlerServesClasspathResource() throws Exception { try (var s = OpenApiServer.builder() .spec(spec) - .jsonMapper(jsonMapper()) .handlers(Map.of()) .exceptionHandler(defaultExceptionHandler()) .port(0) @@ -67,7 +62,7 @@ void specHandlerServesClasspathResource() throws Exception { @Test void extraHandlerExceptionFlowsThroughExceptionHandler() throws Exception { - HttpHandler boom = + com.sun.net.httpserver.HttpHandler boom = ex -> { throw new RuntimeException("boom"); }; @@ -75,7 +70,6 @@ void extraHandlerExceptionFlowsThroughExceptionHandler() throws Exception { try (var s = OpenApiServer.builder() .spec(spec) - .jsonMapper(jsonMapper()) .handlers(Map.of()) .exceptionHandler(defaultExceptionHandler()) .port(0) @@ -93,15 +87,4 @@ void extraHandlerExceptionFlowsThroughExceptionHandler() throws Exception { assertThat(resp.statusCode()).isEqualTo(500); } } - - @Test - void existingPublicConstructorStillWorks() { - try { - try (var s = new OpenApiServer(spec, jsonMapper(), Map.of(), defaultExceptionHandler(), 0)) { - assertThat(s.listenPort()).isGreaterThan(0); - } - } catch (IOException io) { - fail(io); - } - } } diff --git a/src/test/java/com/retailsvc/http/JsonMapperTest.java b/src/test/java/com/retailsvc/http/JsonMapperTest.java deleted file mode 100644 index 8c01dfa..0000000 --- a/src/test/java/com/retailsvc/http/JsonMapperTest.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.retailsvc.http; - -import static org.assertj.core.api.Assertions.assertThat; - -import org.junit.jupiter.api.Test; - -class JsonMapperTest { - @Test - void usableAsLambda() { - JsonMapper m = String::new; - assertThat(m.mapFrom("hello".getBytes())).isEqualTo("hello"); - } -} diff --git a/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java b/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java index c9a2f3b..0e61ba0 100644 --- a/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java +++ b/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java @@ -13,19 +13,12 @@ class OpenApiServerBuilderTest { private final Spec spec = testSpec(); - private final JsonMapper jsonMapper = body -> new java.util.HashMap(); @Test void buildsWithRequiredFieldsOnly() { assertDoesNotThrow( () -> { - try (var _ = - OpenApiServer.builder() - .spec(spec) - .jsonMapper(jsonMapper) - .handlers(emptyMap()) - .port(0) - .build()) { + try (var _ = OpenApiServer.builder().spec(spec).handlers(emptyMap()).port(0).build()) { // close on exit } }); @@ -35,11 +28,7 @@ void buildsWithRequiredFieldsOnly() { void rejectsDuplicateExtraPathOnSecondAddHandler() { HttpHandler duplicate = Handlers.aliveHandler(); OpenApiServer.Builder b = - OpenApiServer.builder() - .spec(spec) - .jsonMapper(jsonMapper) - .handlers(emptyMap()) - .addHandler("/alive", duplicate); + OpenApiServer.builder().spec(spec).handlers(emptyMap()).addHandler("/alive", duplicate); assertThatThrownBy(() -> b.addHandler("/alive", duplicate)) .isInstanceOf(IllegalStateException.class) @@ -52,7 +41,6 @@ void rejectsExtraPathEqualToSpecBasePathAtBuildTime() { OpenApiServer.Builder b = OpenApiServer.builder() .spec(spec) - .jsonMapper(jsonMapper) .handlers(emptyMap()) .addHandler("/api", Handlers.aliveHandler()) .port(0); @@ -78,7 +66,6 @@ void buildsWithShutdownTimeout() { try (var _ = OpenApiServer.builder() .spec(spec) - .jsonMapper(jsonMapper) .handlers(emptyMap()) .port(0) .shutdownTimeoutSeconds(2) @@ -90,13 +77,7 @@ void buildsWithShutdownTimeout() { @Test void stopRejectsNegativeDelay() throws Exception { - try (var s = - OpenApiServer.builder() - .spec(spec) - .jsonMapper(jsonMapper) - .handlers(emptyMap()) - .port(0) - .build()) { + try (var s = OpenApiServer.builder().spec(spec).handlers(emptyMap()).port(0).build()) { assertThatThrownBy(() -> s.stop(-1)) .isInstanceOf(IllegalArgumentException.class) @@ -106,26 +87,45 @@ void stopRejectsNegativeDelay() throws Exception { @Test void stopWithZeroSucceeds() throws Exception { - var s = - OpenApiServer.builder() - .spec(spec) - .jsonMapper(jsonMapper) - .handlers(emptyMap()) - .port(0) - .build(); + var s = OpenApiServer.builder().spec(spec).handlers(emptyMap()).port(0).build(); assertDoesNotThrow(() -> s.stop(0)); } @Test void rejectsNullSpec() { - OpenApiServer.Builder b = - OpenApiServer.builder().jsonMapper(jsonMapper).handlers(emptyMap()).port(0); + OpenApiServer.Builder b = OpenApiServer.builder().handlers(emptyMap()).port(0); assertThatThrownBy(b::build) .isInstanceOf(NullPointerException.class) .hasMessageContaining("Spec"); } + @Test + void bodyMapperRejectsNullMediaType() { + OpenApiServer.Builder b = OpenApiServer.builder(); + TypeMapper noopMapper = + new TypeMapper() { + @Override + public Object readFrom(byte[] body, String contentTypeHeader) { + return null; + } + + @Override + public byte[] writeTo(Object value) { + return new byte[0]; + } + }; + assertThatThrownBy(() -> b.bodyMapper(null, noopMapper)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void bodyMapperRejectsNullMapper() { + OpenApiServer.Builder b = OpenApiServer.builder(); + assertThatThrownBy(() -> b.bodyMapper("application/json", null)) + .isInstanceOf(NullPointerException.class); + } + private static Spec testSpec() { Map raw = Map.of( diff --git a/src/test/java/com/retailsvc/http/OpenApiServerTest.java b/src/test/java/com/retailsvc/http/OpenApiServerTest.java index 9c05976..a325c74 100644 --- a/src/test/java/com/retailsvc/http/OpenApiServerTest.java +++ b/src/test/java/com/retailsvc/http/OpenApiServerTest.java @@ -5,7 +5,6 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import com.retailsvc.http.spec.Spec; -import com.sun.net.httpserver.HttpHandler; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -15,17 +14,14 @@ @ExtendWith(MockitoExtension.class) class OpenApiServerTest { - ExceptionHandler onError = Handlers.defaultExceptionHandler(); - JsonMapper jsonMapper = body -> new java.util.HashMap(); - @Test void shouldStartHttpServerWithValidConfiguration() { Spec validSpec = testSpec(); - Map handlers = emptyMap(); assertDoesNotThrow( () -> { - try (var _ = new OpenApiServer(validSpec, jsonMapper, handlers, onError, 0)) { + try (var _ = + OpenApiServer.builder().spec(validSpec).handlers(emptyMap()).port(0).build()) { // also close on exit } }); @@ -33,28 +29,16 @@ void shouldStartHttpServerWithValidConfiguration() { @Test void shouldThrowExceptionWhenSpecIsNull() { - Map handlers = emptyMap(); - - assertThatThrownBy(() -> new OpenApiServer(null, jsonMapper, handlers, onError)) + assertThatThrownBy(() -> OpenApiServer.builder().handlers(emptyMap()).port(0).build()) .isInstanceOf(NullPointerException.class) .hasMessageContaining("Spec must not be null"); } - @Test - void shouldThrowExceptionWhenJsonMapperIsNull() { - Spec validSpec = testSpec(); - Map handlers = emptyMap(); - - assertThatThrownBy(() -> new OpenApiServer(validSpec, null, handlers, onError)) - .isInstanceOf(NullPointerException.class) - .hasMessageContaining("JsonMapper must not be null"); - } - @Test void shouldThrowExceptionWhenHandlersMapIsNull() { Spec validSpec = testSpec(); - assertThatThrownBy(() -> new OpenApiServer(validSpec, jsonMapper, null, onError)) + assertThatThrownBy(() -> OpenApiServer.builder().spec(validSpec).port(0).build()) .isInstanceOf(NullPointerException.class) .hasMessageContaining("handlers must not be null"); } @@ -62,8 +46,9 @@ void shouldThrowExceptionWhenHandlersMapIsNull() { @Test void testExceptionIsThrownOnInvalidHttpPort() { Spec validSpec = testSpec(); - Map handlers = emptyMap(); - assertThatThrownBy(() -> new OpenApiServer(validSpec, jsonMapper, handlers, onError, -1)) + + assertThatThrownBy( + () -> OpenApiServer.builder().spec(validSpec).handlers(emptyMap()).port(-1).build()) .isInstanceOf(IllegalArgumentException.class); } diff --git a/src/test/java/com/retailsvc/http/ServerBaseTest.java b/src/test/java/com/retailsvc/http/ServerBaseTest.java index a0eb8cf..661c48d 100644 --- a/src/test/java/com/retailsvc/http/ServerBaseTest.java +++ b/src/test/java/com/retailsvc/http/ServerBaseTest.java @@ -1,6 +1,5 @@ package com.retailsvc.http; -import static com.retailsvc.http.Handlers.defaultExceptionHandler; import static java.net.http.HttpClient.Version.HTTP_1_1; import static java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor; import static org.assertj.core.api.Assertions.fail; @@ -45,13 +44,9 @@ void tearDown() { Optional.ofNullable(server).ifPresent(OpenApiServer::close); } - protected JsonMapper jsonMapper() { - return body -> gson.fromJson(new String(body), Object.class); - } - protected OpenApiServer newServer(Map handlers) { try { - server = new OpenApiServer(spec, jsonMapper(), handlers, defaultExceptionHandler(), 0); + server = OpenApiServer.builder().spec(spec).handlers(handlers).port(0).build(); return server; } catch (Exception e) { fail(e); diff --git a/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java b/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java new file mode 100644 index 0000000..069ee93 --- /dev/null +++ b/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java @@ -0,0 +1,106 @@ +package com.retailsvc.http; + +import static java.net.http.HttpClient.Version.HTTP_1_1; +import static java.net.http.HttpResponse.BodyHandlers.ofString; +import static java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.sun.net.httpserver.HttpHandler; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublishers; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class TypeMapperRegistrationTest extends ServerBaseTest { + + // Valid body satisfying PostDataRequest schema (requires aList + feelingGood) + private static final String VALID_POST_BODY = "{\"aList\":[\"x\"],\"feelingGood\":true}"; + + @Test + void gsonFallbackIsAutoRegisteredWhenNoJsonMapperConfigured() throws Exception { + HttpHandler echo = + ex -> { + Object parsed = Request.parsed(); + byte[] out = gson.toJson(parsed).getBytes(StandardCharsets.UTF_8); + ex.getResponseHeaders().add("Content-Type", "application/json"); + ex.sendResponseHeaders(200, out.length); + ex.getResponseBody().write(out); + ex.close(); + }; + server = + OpenApiServer.builder() + .spec(spec) + .handlers(Map.of("get-data", echo, "post-data", echo)) + .port(0) + .build(); + HttpClient client = + HttpClient.newBuilder() + .executor(newVirtualThreadPerTaskExecutor()) + .version(HTTP_1_1) + .build(); + var resp = + client.send( + HttpRequest.newBuilder() + .uri(URI.create("http://localhost:%d/api/v1/data".formatted(server.listenPort()))) + .header("Content-Type", "application/json") + .POST(BodyPublishers.ofString(VALID_POST_BODY)) + .build(), + ofString()); + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.body()).contains("\"aList\""); + } + + @Test + void userSuppliedMapperOverridesDefault() throws Exception { + TypeMapper marker = + new TypeMapper() { + @Override + public Object readFrom(byte[] b, String h) { + return Map.of("aList", java.util.List.of("x"), "feelingGood", true); + } + + @Override + public byte[] writeTo(Object v) { + return "ignored".getBytes(StandardCharsets.UTF_8); + } + }; + HttpHandler echo = + ex -> { + ex.sendResponseHeaders(200, -1); + ex.close(); + }; + OpenApiServer s = + OpenApiServer.builder() + .spec(spec) + .bodyMapper("application/json", marker) + .handlers(Map.of("get-data", echo, "post-data", echo)) + .port(0) + .build(); + s.close(); + } + + @Test + void bodyMapperRejectsNullArgs() { + var b = OpenApiServer.builder(); + assertThatThrownBy(() -> b.bodyMapper(null, new GsonOnlyMapper())) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> b.bodyMapper("application/json", null)) + .isInstanceOf(NullPointerException.class); + } + + private static final class GsonOnlyMapper implements TypeMapper { + @Override + public Object readFrom(byte[] b, String h) { + return null; + } + + @Override + public byte[] writeTo(Object v) { + return new byte[0]; + } + } +} diff --git a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java index 429f98c..96b66ae 100644 --- a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java +++ b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java @@ -4,10 +4,10 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; -import com.retailsvc.http.JsonMapper; import com.retailsvc.http.MethodNotAllowedException; import com.retailsvc.http.NotFoundException; import com.retailsvc.http.Request; +import com.retailsvc.http.TypeMapper; import com.retailsvc.http.ValidationException; import com.retailsvc.http.spec.HttpMethod; import com.retailsvc.http.spec.Info; @@ -27,6 +27,7 @@ import com.sun.net.httpserver.HttpExchange; import java.io.ByteArrayInputStream; import java.net.URI; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import java.util.Optional; @@ -61,9 +62,21 @@ private Spec specWith(Operation... ops) { } private Filter newFilter(Spec spec) { - JsonMapper m = String::new; + TypeMapper textMapper = + new TypeMapper() { + @Override + public Object readFrom(byte[] body, String contentTypeHeader) { + return new String(body, StandardCharsets.UTF_8); + } + + @Override + public byte[] writeTo(Object value) { + return String.valueOf(value).getBytes(StandardCharsets.UTF_8); + } + }; + Map mappers = Map.of("application/json", textMapper); return new RequestPreparationFilter( - spec, new Router(spec.operations()), new DefaultValidator(spec::resolveSchema), m); + spec, new Router(spec.operations()), new DefaultValidator(spec::resolveSchema), mappers); } @Test diff --git a/src/test/java/com/retailsvc/http/start/ServerLauncher.java b/src/test/java/com/retailsvc/http/start/ServerLauncher.java index 08c004e..4e83c1f 100644 --- a/src/test/java/com/retailsvc/http/start/ServerLauncher.java +++ b/src/test/java/com/retailsvc/http/start/ServerLauncher.java @@ -1,9 +1,7 @@ package com.retailsvc.http.start; -import com.google.gson.Gson; import com.retailsvc.http.ExceptionHandler; import com.retailsvc.http.Handlers; -import com.retailsvc.http.JsonMapper; import com.retailsvc.http.OpenApiServer; import com.retailsvc.http.spec.Spec; import com.sun.net.httpserver.HttpHandler; @@ -26,8 +24,6 @@ static void main() throws Exception { public ServerLauncher() throws IOException { long t0 = System.currentTimeMillis(); - final Gson gson = new Gson(); - Map raw; try (InputStream in = ServerLauncher.class.getResourceAsStream("/openapi.yaml")) { raw = new Yaml().load(in); @@ -42,11 +38,13 @@ public ServerLauncher() throws IOException { handlers.put("path-params", new ParamHandler()); handlers.put("path-params-multi", new ParamHandler()); - JsonMapper mapper = body -> gson.fromJson(new String(body), Object.class); - ExceptionHandler exceptionHandler = Handlers.defaultExceptionHandler(); - new OpenApiServer(spec, mapper, handlers, exceptionHandler); + OpenApiServer.builder() + .spec(spec) + .handlers(handlers) + .exceptionHandler(exceptionHandler) + .build(); LOG.info("Application started in {}ms", System.currentTimeMillis() - t0); } } From ff7e63320b7627049b568570c9a04e3bdec97b0c Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 15:32:54 +0200 Subject: [PATCH 15/50] refactor: Use top-level Locale import in OpenApiServer --- src/main/java/com/retailsvc/http/OpenApiServer.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index b9d15dc..f0069cc 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -18,6 +18,7 @@ import java.io.IOException; import java.net.InetSocketAddress; import java.util.LinkedHashMap; +import java.util.Locale; import java.util.Map; import java.util.Optional; import org.slf4j.Logger; @@ -132,7 +133,7 @@ public Builder spec(Spec spec) { public Builder bodyMapper(String mediaType, TypeMapper mapper) { requireNonNull(mediaType, "mediaType must not be null"); requireNonNull(mapper, "mapper must not be null"); - bodyMappers.put(mediaType.toLowerCase(java.util.Locale.ROOT), mapper); + bodyMappers.put(mediaType.toLowerCase(Locale.ROOT), mapper); return this; } From 03a8ba74c1c88d7ec88d521dfd2322367f0d05bf Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 15:37:15 +0200 Subject: [PATCH 16/50] refactor: Move static Request accessors to internal LegacyRequestAccess --- .../retailsvc/http/internal/DispatchHandler.java | 3 +-- .../LegacyRequestAccess.java} | 10 ++++++---- .../com/retailsvc/http/internal/RequestContext.java | 4 ++-- .../http/internal/RequestPreparationFilter.java | 3 +-- src/test/java/com/retailsvc/http/RequestTest.java | 13 +++++++------ .../retailsvc/http/TypeMapperRegistrationTest.java | 3 ++- .../http/internal/DispatchHandlerTest.java | 3 +-- .../http/internal/RequestPreparationFilterTest.java | 5 ++--- .../java/com/retailsvc/http/start/EchoHandler.java | 4 ++-- .../com/retailsvc/http/start/FormEchoHandler.java | 4 ++-- .../com/retailsvc/http/start/PostDataHandler.java | 4 ++-- .../http/start/PostListObjectsHandler.java | 4 ++-- .../com/retailsvc/http/start/TextEchoHandler.java | 4 ++-- 13 files changed, 32 insertions(+), 32 deletions(-) rename src/main/java/com/retailsvc/http/{Request.java => internal/LegacyRequestAccess.java} (88%) diff --git a/src/main/java/com/retailsvc/http/internal/DispatchHandler.java b/src/main/java/com/retailsvc/http/internal/DispatchHandler.java index 8f02e80..95408f9 100644 --- a/src/main/java/com/retailsvc/http/internal/DispatchHandler.java +++ b/src/main/java/com/retailsvc/http/internal/DispatchHandler.java @@ -1,7 +1,6 @@ package com.retailsvc.http.internal; import com.retailsvc.http.MissingOperationHandlerException; -import com.retailsvc.http.Request; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import java.io.IOException; @@ -16,7 +15,7 @@ public DispatchHandler(Map handlers) { @Override public void handle(HttpExchange exchange) throws IOException { - String opId = Request.operationId(); + String opId = LegacyRequestAccess.operationId(); HttpHandler h = handlers.get(opId); if (h == null) { throw new MissingOperationHandlerException(opId); diff --git a/src/main/java/com/retailsvc/http/Request.java b/src/main/java/com/retailsvc/http/internal/LegacyRequestAccess.java similarity index 88% rename from src/main/java/com/retailsvc/http/Request.java rename to src/main/java/com/retailsvc/http/internal/LegacyRequestAccess.java index 2df6ce9..73305b4 100644 --- a/src/main/java/com/retailsvc/http/Request.java +++ b/src/main/java/com/retailsvc/http/internal/LegacyRequestAccess.java @@ -1,6 +1,5 @@ -package com.retailsvc.http; +package com.retailsvc.http.internal; -import com.retailsvc.http.internal.RequestContext; import java.util.Map; /** @@ -13,13 +12,16 @@ *

If a handler dispatches work to a non-structured executor (i.e. not a {@code * StructuredTaskScope}-managed thread), it must capture the values it needs before submitting — the * {@link ScopedValue} is not visible from arbitrary worker threads. + * + * @deprecated Temporary scaffolding; will be deleted in a future task. */ -public final class Request { +@Deprecated +public final class LegacyRequestAccess { /** Bound by {@code RequestPreparationFilter} for the duration of each request. */ public static final ScopedValue CONTEXT = ScopedValue.newInstance(); - private Request() {} + private LegacyRequestAccess() {} /** * Returns the full per-request context. Use this when a handler reads more than one field — every diff --git a/src/main/java/com/retailsvc/http/internal/RequestContext.java b/src/main/java/com/retailsvc/http/internal/RequestContext.java index 70b8ee2..581f47f 100644 --- a/src/main/java/com/retailsvc/http/internal/RequestContext.java +++ b/src/main/java/com/retailsvc/http/internal/RequestContext.java @@ -6,8 +6,8 @@ /** * Immutable per-request data populated by {@link RequestPreparationFilter} and read by handlers via - * {@link com.retailsvc.http.Request}. Bound to a {@link ScopedValue} for the duration of a single - * request — never written to the {@code HttpExchange}'s context-shared attribute map. + * {@link LegacyRequestAccess}. Bound to a {@link ScopedValue} for the duration of a single request + * — never written to the {@code HttpExchange}'s context-shared attribute map. * *

{@code equals}, {@code hashCode}, and {@code toString} are overridden because the record * carries a {@code byte[]} component: the auto-generated implementations use reference equality on diff --git a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java index fec087a..382a490 100644 --- a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java +++ b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java @@ -2,7 +2,6 @@ import com.retailsvc.http.MethodNotAllowedException; import com.retailsvc.http.NotFoundException; -import com.retailsvc.http.Request; import com.retailsvc.http.TypeMapper; import com.retailsvc.http.ValidationException; import com.retailsvc.http.spec.HttpMethod; @@ -71,7 +70,7 @@ public void doFilter(HttpExchange exchange, Chain chain) throws IOException { private static void runWithRequestContext(RequestContext ctx, IORunnable work) throws IOException { try { - ScopedValue.where(Request.CONTEXT, ctx) + ScopedValue.where(LegacyRequestAccess.CONTEXT, ctx) .call( () -> { work.run(); diff --git a/src/test/java/com/retailsvc/http/RequestTest.java b/src/test/java/com/retailsvc/http/RequestTest.java index ab47779..c2c7e56 100644 --- a/src/test/java/com/retailsvc/http/RequestTest.java +++ b/src/test/java/com/retailsvc/http/RequestTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; +import com.retailsvc.http.internal.LegacyRequestAccess; import com.retailsvc.http.internal.RequestContext; import java.util.Map; import java.util.NoSuchElementException; @@ -21,13 +22,13 @@ void readsBoundContext() throws Exception { AtomicReference seenOpId = new AtomicReference<>(); AtomicReference> seenPathParams = new AtomicReference<>(); - ScopedValue.where(Request.CONTEXT, ctx) + ScopedValue.where(LegacyRequestAccess.CONTEXT, ctx) .call( () -> { - seenBytes.set(Request.bytes()); - seenParsed.set(Request.parsed()); - seenOpId.set(Request.operationId()); - seenPathParams.set(Request.pathParams()); + seenBytes.set(LegacyRequestAccess.bytes()); + seenParsed.set(LegacyRequestAccess.parsed()); + seenOpId.set(LegacyRequestAccess.operationId()); + seenPathParams.set(LegacyRequestAccess.pathParams()); return null; }); @@ -39,6 +40,6 @@ void readsBoundContext() throws Exception { @Test void readingOutsideScopeThrows() { - assertThrows(NoSuchElementException.class, Request::bytes); + assertThrows(NoSuchElementException.class, LegacyRequestAccess::bytes); } } diff --git a/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java b/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java index 069ee93..5f707b1 100644 --- a/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java +++ b/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java @@ -6,6 +6,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.retailsvc.http.internal.LegacyRequestAccess; import com.sun.net.httpserver.HttpHandler; import java.net.URI; import java.net.http.HttpClient; @@ -24,7 +25,7 @@ class TypeMapperRegistrationTest extends ServerBaseTest { void gsonFallbackIsAutoRegisteredWhenNoJsonMapperConfigured() throws Exception { HttpHandler echo = ex -> { - Object parsed = Request.parsed(); + Object parsed = LegacyRequestAccess.parsed(); byte[] out = gson.toJson(parsed).getBytes(StandardCharsets.UTF_8); ex.getResponseHeaders().add("Content-Type", "application/json"); ex.sendResponseHeaders(200, out.length); diff --git a/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java b/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java index b34740d..8da8b4b 100644 --- a/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java +++ b/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java @@ -4,7 +4,6 @@ import static org.mockito.Mockito.mock; import com.retailsvc.http.MissingOperationHandlerException; -import com.retailsvc.http.Request; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import java.util.Map; @@ -16,7 +15,7 @@ class DispatchHandlerTest { private static void withOperationId( String operationId, ScopedValue.CallableOp body) throws Exception { RequestContext ctx = new RequestContext(new byte[0], null, operationId, Map.of()); - ScopedValue.where(Request.CONTEXT, ctx).call(body); + ScopedValue.where(LegacyRequestAccess.CONTEXT, ctx).call(body); } @Test diff --git a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java index 96b66ae..7f71197 100644 --- a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java +++ b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java @@ -6,7 +6,6 @@ import com.retailsvc.http.MethodNotAllowedException; import com.retailsvc.http.NotFoundException; -import com.retailsvc.http.Request; import com.retailsvc.http.TypeMapper; import com.retailsvc.http.ValidationException; import com.retailsvc.http.spec.HttpMethod; @@ -100,8 +99,8 @@ void successPathBindsRequestContextDuringChain() throws Exception { Filter.Chain chain = mock(Filter.Chain.class); Mockito.doAnswer( inv -> { - seenOpId.set(Request.operationId()); - seenPathParams.set(Request.pathParams()); + seenOpId.set(LegacyRequestAccess.operationId()); + seenPathParams.set(LegacyRequestAccess.pathParams()); return null; }) .when(chain) diff --git a/src/test/java/com/retailsvc/http/start/EchoHandler.java b/src/test/java/com/retailsvc/http/start/EchoHandler.java index f02a767..43e7067 100644 --- a/src/test/java/com/retailsvc/http/start/EchoHandler.java +++ b/src/test/java/com/retailsvc/http/start/EchoHandler.java @@ -1,6 +1,6 @@ package com.retailsvc.http.start; -import com.retailsvc.http.Request; +import com.retailsvc.http.internal.LegacyRequestAccess; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import java.io.IOException; @@ -15,7 +15,7 @@ public class EchoHandler implements HttpHandler { @Override public void handle(HttpExchange exchange) throws IOException { - byte[] bytes = Request.bytes(); + byte[] bytes = LegacyRequestAccess.bytes(); if (bytes.length == 0) { LOG.debug("No bytes available to read from the request body"); diff --git a/src/test/java/com/retailsvc/http/start/FormEchoHandler.java b/src/test/java/com/retailsvc/http/start/FormEchoHandler.java index 24249af..f4f95d7 100644 --- a/src/test/java/com/retailsvc/http/start/FormEchoHandler.java +++ b/src/test/java/com/retailsvc/http/start/FormEchoHandler.java @@ -2,7 +2,7 @@ import static java.net.HttpURLConnection.HTTP_OK; -import com.retailsvc.http.Request; +import com.retailsvc.http.internal.LegacyRequestAccess; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import java.io.IOException; @@ -13,7 +13,7 @@ public class FormEchoHandler implements HttpHandler { @Override public void handle(HttpExchange exchange) throws IOException { - Object parsed = Request.parsed(); + Object parsed = LegacyRequestAccess.parsed(); byte[] body = String.valueOf(parsed).getBytes(StandardCharsets.UTF_8); try (exchange) { exchange.getResponseHeaders().add("Content-Type", "text/plain; charset=utf-8"); diff --git a/src/test/java/com/retailsvc/http/start/PostDataHandler.java b/src/test/java/com/retailsvc/http/start/PostDataHandler.java index 4f2a1d6..783d526 100644 --- a/src/test/java/com/retailsvc/http/start/PostDataHandler.java +++ b/src/test/java/com/retailsvc/http/start/PostDataHandler.java @@ -1,6 +1,6 @@ package com.retailsvc.http.start; -import com.retailsvc.http.Request; +import com.retailsvc.http.internal.LegacyRequestAccess; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import java.io.IOException; @@ -16,7 +16,7 @@ public class PostDataHandler implements HttpHandler { public void handle(HttpExchange exchange) throws IOException { LOG.debug("POST /data"); - byte[] bytes = Request.bytes(); + byte[] bytes = LegacyRequestAccess.bytes(); if (bytes.length == 0) { LOG.debug("No bytes available to read from the request body"); diff --git a/src/test/java/com/retailsvc/http/start/PostListObjectsHandler.java b/src/test/java/com/retailsvc/http/start/PostListObjectsHandler.java index 9d6c559..e31a6f4 100644 --- a/src/test/java/com/retailsvc/http/start/PostListObjectsHandler.java +++ b/src/test/java/com/retailsvc/http/start/PostListObjectsHandler.java @@ -1,6 +1,6 @@ package com.retailsvc.http.start; -import com.retailsvc.http.Request; +import com.retailsvc.http.internal.LegacyRequestAccess; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import java.io.IOException; @@ -16,7 +16,7 @@ public class PostListObjectsHandler implements HttpHandler { public void handle(HttpExchange exchange) throws IOException { LOG.debug("POST /list/objects"); - byte[] bytes = Request.bytes(); + byte[] bytes = LegacyRequestAccess.bytes(); if (bytes.length == 0) { LOG.debug("No bytes available to read from the request body"); diff --git a/src/test/java/com/retailsvc/http/start/TextEchoHandler.java b/src/test/java/com/retailsvc/http/start/TextEchoHandler.java index 8c69826..fc72978 100644 --- a/src/test/java/com/retailsvc/http/start/TextEchoHandler.java +++ b/src/test/java/com/retailsvc/http/start/TextEchoHandler.java @@ -2,7 +2,7 @@ import static java.net.HttpURLConnection.HTTP_OK; -import com.retailsvc.http.Request; +import com.retailsvc.http.internal.LegacyRequestAccess; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import java.io.IOException; @@ -13,7 +13,7 @@ public class TextEchoHandler implements HttpHandler { @Override public void handle(HttpExchange exchange) throws IOException { - String parsed = (String) Request.parsed(); + String parsed = (String) LegacyRequestAccess.parsed(); byte[] body = parsed.getBytes(StandardCharsets.UTF_8); try (exchange) { exchange.getResponseHeaders().add("Content-Type", "text/plain; charset=utf-8"); From c263cda6c711af599680115583b2ae1905a2f5f9 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 15:40:43 +0200 Subject: [PATCH 17/50] refactor: Drop @Deprecated from transitional LegacyRequestAccess --- .../java/com/retailsvc/http/internal/LegacyRequestAccess.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/retailsvc/http/internal/LegacyRequestAccess.java b/src/main/java/com/retailsvc/http/internal/LegacyRequestAccess.java index 73305b4..cbe023d 100644 --- a/src/main/java/com/retailsvc/http/internal/LegacyRequestAccess.java +++ b/src/main/java/com/retailsvc/http/internal/LegacyRequestAccess.java @@ -13,9 +13,9 @@ * StructuredTaskScope}-managed thread), it must capture the values it needs before submitting — the * {@link ScopedValue} is not visible from arbitrary worker threads. * - * @deprecated Temporary scaffolding; will be deleted in a future task. + *

Temporary scaffolding bridging the static-accessor era to the per-request {@code Request} + * handle. Removed in a follow-up task once the new handler API replaces all consumers. */ -@Deprecated public final class LegacyRequestAccess { /** Bound by {@code RequestPreparationFilter} for the duration of each request. */ From 33ad29fd9a28813de2d430ed752e5c892e7f9882 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 15:51:26 +0200 Subject: [PATCH 18/50] feat: Add Request, ResponseBuilder, RequestHandler types --- src/main/java/com/retailsvc/http/Request.java | 58 +++++++++ .../com/retailsvc/http/RequestHandler.java | 12 ++ .../com/retailsvc/http/ResponseBuilder.java | 35 ++++++ .../http/internal/DefaultResponseBuilder.java | 114 ++++++++++++++++++ .../http/RequestResponseGatewayTest.java | 105 ++++++++++++++++ 5 files changed, 324 insertions(+) create mode 100644 src/main/java/com/retailsvc/http/Request.java create mode 100644 src/main/java/com/retailsvc/http/RequestHandler.java create mode 100644 src/main/java/com/retailsvc/http/ResponseBuilder.java create mode 100644 src/main/java/com/retailsvc/http/internal/DefaultResponseBuilder.java create mode 100644 src/test/java/com/retailsvc/http/RequestResponseGatewayTest.java diff --git a/src/main/java/com/retailsvc/http/Request.java b/src/main/java/com/retailsvc/http/Request.java new file mode 100644 index 0000000..4d5810a --- /dev/null +++ b/src/main/java/com/retailsvc/http/Request.java @@ -0,0 +1,58 @@ +package com.retailsvc.http; + +import com.retailsvc.http.internal.DefaultResponseBuilder; +import com.sun.net.httpserver.HttpExchange; +import java.util.Map; + +/** + * The per-request handle passed to {@link RequestHandler}. Carries the parsed body, path + * parameters, operation ID, and a fluent {@link ResponseBuilder} for writing the response. + */ +public final class Request { + + private final HttpExchange exchange; + private final byte[] body; + private final Object parsed; + private final String operationId; + private final Map pathParameters; + private final Map bodyMappers; + + public Request( + HttpExchange exchange, + byte[] body, + Object parsed, + String operationId, + Map pathParameters, + Map bodyMappers) { + this.exchange = exchange; + this.body = body; + this.parsed = parsed; + this.operationId = operationId; + this.pathParameters = pathParameters; + this.bodyMappers = bodyMappers; + } + + public byte[] bytes() { + return body; + } + + public Object parsed() { + return parsed; + } + + public String operationId() { + return operationId; + } + + public Map pathParams() { + return pathParameters; + } + + public String header(String name) { + return exchange.getRequestHeaders().getFirst(name); + } + + public ResponseBuilder respond(int status) { + return new DefaultResponseBuilder(exchange, status, bodyMappers); + } +} diff --git a/src/main/java/com/retailsvc/http/RequestHandler.java b/src/main/java/com/retailsvc/http/RequestHandler.java new file mode 100644 index 0000000..db15f50 --- /dev/null +++ b/src/main/java/com/retailsvc/http/RequestHandler.java @@ -0,0 +1,12 @@ +package com.retailsvc.http; + +import java.io.IOException; + +/** + * Handles a single request identified by OpenAPI {@code operationId}. Registered on {@link + * OpenApiServer.Builder#handlers(java.util.Map)} by operation ID. + */ +@FunctionalInterface +public interface RequestHandler { + void handle(Request request) throws IOException; +} diff --git a/src/main/java/com/retailsvc/http/ResponseBuilder.java b/src/main/java/com/retailsvc/http/ResponseBuilder.java new file mode 100644 index 0000000..6f703e1 --- /dev/null +++ b/src/main/java/com/retailsvc/http/ResponseBuilder.java @@ -0,0 +1,35 @@ +package com.retailsvc.http; + +import java.io.IOException; +import java.io.OutputStream; + +/** + * Fluent response builder returned by {@link Request#respond(int)}. Each {@code Request} permits + * exactly one terminal call ({@link #empty()}, {@link #bytes(byte[])}, {@link #text(String)}, + * {@link #json(Object)}, {@link #body(String, Object)}, {@link #stream()}, or {@link + * #stream(long)}); calling any of them after the first throws {@link IllegalStateException}. {@link + * #header(String, String)} / {@link #contentType(String)} must be called before the terminal. + * + *

Note: a {@code problem(...)} terminal is deferred — no public {@code ProblemDetail} type + * exists yet; only the internal {@code ProblemDetailRenderer} is available. + */ +public interface ResponseBuilder { + + ResponseBuilder header(String name, String value); + + ResponseBuilder contentType(String contentType); + + void empty() throws IOException; + + void bytes(byte[] body) throws IOException; + + void text(String body) throws IOException; + + void json(Object body) throws IOException; + + void body(String mediaType, Object body) throws IOException; + + OutputStream stream() throws IOException; + + OutputStream stream(long length) throws IOException; +} diff --git a/src/main/java/com/retailsvc/http/internal/DefaultResponseBuilder.java b/src/main/java/com/retailsvc/http/internal/DefaultResponseBuilder.java new file mode 100644 index 0000000..beb19c0 --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/DefaultResponseBuilder.java @@ -0,0 +1,114 @@ +package com.retailsvc.http.internal; + +import com.retailsvc.http.ResponseBuilder; +import com.retailsvc.http.TypeMapper; +import com.sun.net.httpserver.HttpExchange; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class DefaultResponseBuilder implements ResponseBuilder { + + private static final String CONTENT_TYPE = "Content-Type"; + + private final HttpExchange exchange; + private final int status; + private final Map mappers; + private final Map pendingHeaders = new LinkedHashMap<>(); + private boolean terminated; + + public DefaultResponseBuilder( + HttpExchange exchange, int status, Map mappers) { + this.exchange = exchange; + this.status = status; + this.mappers = mappers; + } + + @Override + public ResponseBuilder header(String name, String value) { + checkNotTerminated(); + pendingHeaders.put(name, value); + return this; + } + + @Override + public ResponseBuilder contentType(String contentType) { + return header(CONTENT_TYPE, contentType); + } + + @Override + public void empty() throws IOException { + terminate(); + applyHeaders(); + exchange.sendResponseHeaders(status, -1); + } + + @Override + public void bytes(byte[] body) throws IOException { + terminate(); + applyHeaders(); + exchange.sendResponseHeaders(status, body.length); + if (body.length > 0) { + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + } + } + + @Override + public void text(String body) throws IOException { + pendingHeaders.putIfAbsent(CONTENT_TYPE, "text/plain; charset=UTF-8"); + bytes(body.getBytes(StandardCharsets.UTF_8)); + } + + @Override + public void json(Object body) throws IOException { + this.body("application/json", body); + } + + @Override + public void body(String mediaType, Object value) throws IOException { + TypeMapper mapper = mappers.get(mediaType.toLowerCase(java.util.Locale.ROOT)); + if (mapper == null) { + throw new IllegalStateException("No TypeMapper registered for " + mediaType); + } + pendingHeaders.putIfAbsent(CONTENT_TYPE, mediaType); + bytes(mapper.writeTo(value)); + } + + @Override + public OutputStream stream() throws IOException { + terminate(); + applyHeaders(); + exchange.sendResponseHeaders(status, 0); + return exchange.getResponseBody(); + } + + @Override + public OutputStream stream(long length) throws IOException { + if (length < 0) { + throw new IllegalArgumentException("length must be non-negative"); + } + terminate(); + applyHeaders(); + exchange.sendResponseHeaders(status, length); + return exchange.getResponseBody(); + } + + private void terminate() { + checkNotTerminated(); + terminated = true; + } + + private void checkNotTerminated() { + if (terminated) { + throw new IllegalStateException("Response already sent"); + } + } + + private void applyHeaders() { + pendingHeaders.forEach(exchange.getResponseHeaders()::add); + } +} diff --git a/src/test/java/com/retailsvc/http/RequestResponseGatewayTest.java b/src/test/java/com/retailsvc/http/RequestResponseGatewayTest.java new file mode 100644 index 0000000..d1f0faf --- /dev/null +++ b/src/test/java/com/retailsvc/http/RequestResponseGatewayTest.java @@ -0,0 +1,105 @@ +package com.retailsvc.http; + +import static java.net.http.HttpClient.Version.HTTP_1_1; +import static java.net.http.HttpResponse.BodyHandlers.ofString; +import static java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor; +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublishers; +import java.util.Map; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +// TODO Task 9: remove @Disabled once Builder.handlers() accepts Map +@Disabled("Enabled in Task 9 once handlers() takes RequestHandler") +class RequestResponseGatewayTest extends ServerBaseTest { + + /** + * Casts a {@code Map} to the raw {@code Map} type so callers compile + * against the existing {@code Builder.handlers(Map)} signature. This cast is + * safe to write here because the class is {@link Disabled} — it never runs until Task 9 replaces + * the stub with a real {@code handlers(Map)} overload. + */ + @SuppressWarnings("unchecked") + private static Map asRawHandlers(Map handlers) { + return handlers; + } + + @Test + void respondJsonWritesBodyAndContentType() throws Exception { + RequestHandler echo = req -> req.respond(200).json(Map.of("op", req.operationId())); + server = + OpenApiServer.builder() + .spec(spec) + .handlers(asRawHandlers(Map.of("getRoot", echo, "postData", echo))) + .port(0) + .build(); + HttpClient client = + HttpClient.newBuilder() + .executor(newVirtualThreadPerTaskExecutor()) + .version(HTTP_1_1) + .build(); + var resp = + client.send( + HttpRequest.newBuilder() + .uri(URI.create("http://localhost:%d/api/v1/data".formatted(server.listenPort()))) + .header("Content-Type", "application/json") + .POST(BodyPublishers.ofString("{\"n\":1}")) + .build(), + ofString()); + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.headers().firstValue("Content-Type")).contains("application/json"); + assertThat(resp.body()).contains("\"op\":\"postData\""); + } + + @Test + void respondEmptyUses204Style() throws Exception { + RequestHandler ok = req -> req.respond(204).empty(); + server = + OpenApiServer.builder() + .spec(spec) + .handlers(asRawHandlers(Map.of("getRoot", ok, "postData", ok))) + .port(0) + .build(); + var resp = + HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder() + .uri(URI.create("http://localhost:%d/api/v1/".formatted(server.listenPort()))) + .GET() + .build(), + ofString()); + assertThat(resp.statusCode()).isEqualTo(204); + assertThat(resp.body()).isEmpty(); + } + + @Test + void respondStreamUsesChunkedEncoding() throws Exception { + RequestHandler streamer = + req -> { + try (var out = req.respond(200).contentType("text/plain").stream()) { + out.write("hello ".getBytes()); + out.write("world".getBytes()); + } + }; + server = + OpenApiServer.builder() + .spec(spec) + .handlers(asRawHandlers(Map.of("getRoot", streamer, "postData", streamer))) + .port(0) + .build(); + var resp = + HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder() + .uri(URI.create("http://localhost:%d/api/v1/".formatted(server.listenPort()))) + .GET() + .build(), + ofString()); + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.body()).isEqualTo("hello world"); + } +} From fd23f8a5de481150287a03bbed665c39ea6ca47c Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 15:55:03 +0200 Subject: [PATCH 19/50] fix: Use responseLength=-1 for empty bytes() responses --- .../com/retailsvc/http/internal/DefaultResponseBuilder.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/retailsvc/http/internal/DefaultResponseBuilder.java b/src/main/java/com/retailsvc/http/internal/DefaultResponseBuilder.java index beb19c0..d69dff7 100644 --- a/src/main/java/com/retailsvc/http/internal/DefaultResponseBuilder.java +++ b/src/main/java/com/retailsvc/http/internal/DefaultResponseBuilder.java @@ -49,7 +49,7 @@ public void empty() throws IOException { public void bytes(byte[] body) throws IOException { terminate(); applyHeaders(); - exchange.sendResponseHeaders(status, body.length); + exchange.sendResponseHeaders(status, body.length == 0 ? -1 : body.length); if (body.length > 0) { try (OutputStream out = exchange.getResponseBody()) { out.write(body); From b9c29157e72ac6fc86a4a7231d2fb5ecd47eb419 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 16:05:54 +0200 Subject: [PATCH 20/50] feat!: Switch handlers to RequestHandler receiving Request --- .../com/retailsvc/http/OpenApiServer.java | 6 ++-- .../http/internal/DispatchHandler.java | 17 +++++---- .../internal/RequestPreparationFilter.java | 21 ++++------- .../com/retailsvc/http/NonJsonBodyIT.java | 9 +++-- .../com/retailsvc/http/OpenApiServerIT.java | 21 ++++------- .../http/RequestResponseGatewayTest.java | 32 ++++++----------- .../java/com/retailsvc/http/RequestTest.java | 34 +++++++++--------- .../com/retailsvc/http/ServerBaseTest.java | 3 +- .../http/TypeMapperRegistrationTest.java | 19 +++------- .../http/internal/DispatchHandlerTest.java | 25 +++++++------ .../RequestPreparationFilterTest.java | 6 ++-- .../com/retailsvc/http/start/EchoHandler.java | 20 ++++------- .../retailsvc/http/start/FormEchoHandler.java | 19 ++++------ .../retailsvc/http/start/GetDataHandler.java | 35 +++++++------------ .../retailsvc/http/start/ParamHandler.java | 16 +++------ .../retailsvc/http/start/PostDataHandler.java | 19 ++++------ .../http/start/PostListObjectsHandler.java | 18 ++++------ .../retailsvc/http/start/ServerLauncher.java | 4 +-- .../retailsvc/http/start/TextEchoHandler.java | 19 ++++------ 19 files changed, 133 insertions(+), 210 deletions(-) diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index f0069cc..2e4b6d4 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -43,7 +43,7 @@ public class OpenApiServer implements AutoCloseable { OpenApiServer( Spec spec, Map bodyMappers, - Map handlers, + Map handlers, ExceptionHandler exceptionHandler, int port, Map extras, @@ -117,7 +117,7 @@ public static final class Builder { private Spec spec; private final LinkedHashMap bodyMappers = new LinkedHashMap<>(); - private Map handlers; + private Map handlers; private ExceptionHandler exceptionHandler; private int port = DEFAULT_PORT; private int shutdownTimeoutSeconds = 0; @@ -137,7 +137,7 @@ public Builder bodyMapper(String mediaType, TypeMapper mapper) { return this; } - public Builder handlers(Map handlers) { + public Builder handlers(Map handlers) { this.handlers = handlers; return this; } diff --git a/src/main/java/com/retailsvc/http/internal/DispatchHandler.java b/src/main/java/com/retailsvc/http/internal/DispatchHandler.java index 95408f9..1ee5a78 100644 --- a/src/main/java/com/retailsvc/http/internal/DispatchHandler.java +++ b/src/main/java/com/retailsvc/http/internal/DispatchHandler.java @@ -1,25 +1,30 @@ package com.retailsvc.http.internal; import com.retailsvc.http.MissingOperationHandlerException; +import com.retailsvc.http.Request; +import com.retailsvc.http.RequestHandler; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import java.io.IOException; import java.util.Map; public final class DispatchHandler implements HttpHandler { - private final Map handlers; - public DispatchHandler(Map handlers) { + public static final ScopedValue CURRENT = ScopedValue.newInstance(); + + private final Map handlers; + + public DispatchHandler(Map handlers) { this.handlers = Map.copyOf(handlers); } @Override public void handle(HttpExchange exchange) throws IOException { - String opId = LegacyRequestAccess.operationId(); - HttpHandler h = handlers.get(opId); + Request request = CURRENT.get(); + RequestHandler h = handlers.get(request.operationId()); if (h == null) { - throw new MissingOperationHandlerException(opId); + throw new MissingOperationHandlerException(request.operationId()); } - h.handle(exchange); + h.handle(request); } } diff --git a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java index 382a490..12ad1f6 100644 --- a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java +++ b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java @@ -2,6 +2,7 @@ import com.retailsvc.http.MethodNotAllowedException; import com.retailsvc.http.NotFoundException; +import com.retailsvc.http.Request; import com.retailsvc.http.TypeMapper; import com.retailsvc.http.ValidationException; import com.retailsvc.http.spec.HttpMethod; @@ -61,34 +62,24 @@ public void doFilter(HttpExchange exchange, Chain chain) throws IOException { validateParameters(exchange, op, match.pathParameters()); Object parsedBody = validateAndParseBody(exchange, op, body); - RequestContext ctx = - new RequestContext(body, parsedBody, op.operationId(), match.pathParameters()); + Request request = + new Request( + exchange, body, parsedBody, op.operationId(), match.pathParameters(), bodyMappers); - runWithRequestContext(ctx, () -> chain.doFilter(exchange)); - } - - private static void runWithRequestContext(RequestContext ctx, IORunnable work) - throws IOException { try { - ScopedValue.where(LegacyRequestAccess.CONTEXT, ctx) + ScopedValue.where(DispatchHandler.CURRENT, request) .call( () -> { - work.run(); + chain.doFilter(exchange); return null; }); } catch (IOException | RuntimeException e) { throw e; } catch (Exception e) { - // Callable.call() throws Exception; nothing else can actually be thrown by the chain. throw new IOException(e); } } - @FunctionalInterface - private interface IORunnable { - void run() throws IOException; - } - private String stripBasePath(String path) { String base = spec.basePath(); if (base == null || base.isEmpty() || base.equals("/")) { diff --git a/src/test/java/com/retailsvc/http/NonJsonBodyIT.java b/src/test/java/com/retailsvc/http/NonJsonBodyIT.java index 5d40d2a..0894f0b 100644 --- a/src/test/java/com/retailsvc/http/NonJsonBodyIT.java +++ b/src/test/java/com/retailsvc/http/NonJsonBodyIT.java @@ -5,7 +5,6 @@ import com.retailsvc.http.start.FormEchoHandler; import com.retailsvc.http.start.TextEchoHandler; -import com.sun.net.httpserver.HttpHandler; import java.net.URI; import java.net.http.HttpRequest; import java.net.http.HttpResponse.BodyHandlers; @@ -16,7 +15,7 @@ class NonJsonBodyIT extends ServerBaseTest { @Test void formUrlEncodedBodyParsedAndCoerced() throws Exception { - Map handlers = Map.of("form-echo", new FormEchoHandler()); + Map handlers = Map.of("form-echo", new FormEchoHandler()); try (var s = newServer(handlers); var client = httpClient()) { var req = postForm(s, "/form-echo", "name=foo&age=30"); @@ -28,7 +27,7 @@ void formUrlEncodedBodyParsedAndCoerced() throws Exception { @Test void formArrayProperty() throws Exception { - Map handlers = Map.of("form-echo", new FormEchoHandler()); + Map handlers = Map.of("form-echo", new FormEchoHandler()); try (var s = newServer(handlers); var client = httpClient()) { var req = postForm(s, "/form-echo", "tags=a&tags=b"); @@ -40,7 +39,7 @@ void formArrayProperty() throws Exception { @Test void formCoercionFailureReturns400() throws Exception { - Map handlers = Map.of("form-echo", new FormEchoHandler()); + Map handlers = Map.of("form-echo", new FormEchoHandler()); try (var s = newServer(handlers); var client = httpClient()) { var req = postForm(s, "/form-echo", "age=abc"); @@ -52,7 +51,7 @@ void formCoercionFailureReturns400() throws Exception { @Test void textPlainBodyParsedAsString() throws Exception { - Map handlers = Map.of("text-echo", new TextEchoHandler()); + Map handlers = Map.of("text-echo", new TextEchoHandler()); try (var s = newServer(handlers); var client = httpClient()) { var req = postWithContentType(s, "/text-echo", "hello", "text/plain; charset=utf-8"); diff --git a/src/test/java/com/retailsvc/http/OpenApiServerIT.java b/src/test/java/com/retailsvc/http/OpenApiServerIT.java index 922bf7d..e929822 100644 --- a/src/test/java/com/retailsvc/http/OpenApiServerIT.java +++ b/src/test/java/com/retailsvc/http/OpenApiServerIT.java @@ -8,7 +8,6 @@ import com.retailsvc.http.start.EchoHandler; import com.retailsvc.http.start.GetDataHandler; -import com.sun.net.httpserver.HttpHandler; import java.io.IOException; import java.net.http.HttpResponse.BodyHandlers; import java.util.Map; @@ -130,7 +129,7 @@ void postDataShouldReturnJsonBody() { @Test void postDataShouldReturnBadRequestOnMissingRequiredProperties() { - Map handlers = Map.of("post-data", new EchoHandler()); + Map handlers = Map.of("post-data", new EchoHandler()); try (var server = newServer(handlers); var client = httpClient()) { @@ -606,8 +605,7 @@ class FormatEmail { @Test void formatEmailShouldReturnBadRequestOnInvalidEmail() { - try (var server = - newServer(Map.of("format-email", exchange -> exchange.sendResponseHeaders(200, -1))); + try (var server = newServer(Map.of("format-email", req -> req.respond(200).empty())); var client = httpClient()) { var request = newRequest(server, path + "?addr=not-an-email", "GET", noBody()); @@ -631,8 +629,7 @@ void formatEmailShouldReturnBadRequestOnInvalidEmail() { @Test void formatEmailShouldReturnOkOnValidEmail() { - try (var server = - newServer(Map.of("format-email", exchange -> exchange.sendResponseHeaders(200, -1))); + try (var server = newServer(Map.of("format-email", req -> req.respond(200).empty())); var client = httpClient()) { var request = newRequest(server, path + "?addr=user%40example.com", "GET", noBody()); @@ -658,8 +655,7 @@ class FormatByte { @Test void formatByteShouldReturnBadRequestOnInvalidBase64() { - try (var server = - newServer(Map.of("format-byte", exchange -> exchange.sendResponseHeaders(200, -1))); + try (var server = newServer(Map.of("format-byte", req -> req.respond(200).empty())); var client = httpClient()) { var request = newRequest(server, path + "?data=not%20base64!!", "GET", noBody()); @@ -683,8 +679,7 @@ void formatByteShouldReturnBadRequestOnInvalidBase64() { @Test void formatByteShouldReturnOkOnValidBase64() { - try (var server = - newServer(Map.of("format-byte", exchange -> exchange.sendResponseHeaders(200, -1))); + try (var server = newServer(Map.of("format-byte", req -> req.respond(200).empty())); var client = httpClient()) { var request = newRequest(server, path + "?data=aGVsbG8%3D", "GET", noBody()); @@ -710,8 +705,7 @@ class FormatInt32 { @Test void formatInt32ShouldReturnBadRequestOnOverflow() { - try (var server = - newServer(Map.of("format-int32", exchange -> exchange.sendResponseHeaders(200, -1))); + try (var server = newServer(Map.of("format-int32", req -> req.respond(200).empty())); var client = httpClient()) { var request = newRequest(server, path + "?n=2147483648", "GET", noBody()); @@ -735,8 +729,7 @@ void formatInt32ShouldReturnBadRequestOnOverflow() { @Test void formatInt32ShouldReturnOkOnValidValue() { - try (var server = - newServer(Map.of("format-int32", exchange -> exchange.sendResponseHeaders(200, -1))); + try (var server = newServer(Map.of("format-int32", req -> req.respond(200).empty())); var client = httpClient()) { var request = newRequest(server, path + "?n=42", "GET", noBody()); diff --git a/src/test/java/com/retailsvc/http/RequestResponseGatewayTest.java b/src/test/java/com/retailsvc/http/RequestResponseGatewayTest.java index d1f0faf..35146ab 100644 --- a/src/test/java/com/retailsvc/http/RequestResponseGatewayTest.java +++ b/src/test/java/com/retailsvc/http/RequestResponseGatewayTest.java @@ -10,31 +10,17 @@ import java.net.http.HttpRequest; import java.net.http.HttpRequest.BodyPublishers; import java.util.Map; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; -// TODO Task 9: remove @Disabled once Builder.handlers() accepts Map -@Disabled("Enabled in Task 9 once handlers() takes RequestHandler") class RequestResponseGatewayTest extends ServerBaseTest { - /** - * Casts a {@code Map} to the raw {@code Map} type so callers compile - * against the existing {@code Builder.handlers(Map)} signature. This cast is - * safe to write here because the class is {@link Disabled} — it never runs until Task 9 replaces - * the stub with a real {@code handlers(Map)} overload. - */ - @SuppressWarnings("unchecked") - private static Map asRawHandlers(Map handlers) { - return handlers; - } - @Test void respondJsonWritesBodyAndContentType() throws Exception { RequestHandler echo = req -> req.respond(200).json(Map.of("op", req.operationId())); server = OpenApiServer.builder() .spec(spec) - .handlers(asRawHandlers(Map.of("getRoot", echo, "postData", echo))) + .handlers(Map.of("get-data", echo, "post-data", echo)) .port(0) .build(); HttpClient client = @@ -47,12 +33,12 @@ void respondJsonWritesBodyAndContentType() throws Exception { HttpRequest.newBuilder() .uri(URI.create("http://localhost:%d/api/v1/data".formatted(server.listenPort()))) .header("Content-Type", "application/json") - .POST(BodyPublishers.ofString("{\"n\":1}")) + .POST(BodyPublishers.ofString("{\"aList\":[\"x\"],\"feelingGood\":true}")) .build(), ofString()); assertThat(resp.statusCode()).isEqualTo(200); assertThat(resp.headers().firstValue("Content-Type")).contains("application/json"); - assertThat(resp.body()).contains("\"op\":\"postData\""); + assertThat(resp.body()).contains("\"op\":\"post-data\""); } @Test @@ -61,14 +47,16 @@ void respondEmptyUses204Style() throws Exception { server = OpenApiServer.builder() .spec(spec) - .handlers(asRawHandlers(Map.of("getRoot", ok, "postData", ok))) + .handlers(Map.of("get-data", ok, "post-data", ok)) .port(0) .build(); var resp = HttpClient.newHttpClient() .send( HttpRequest.newBuilder() - .uri(URI.create("http://localhost:%d/api/v1/".formatted(server.listenPort()))) + .uri( + URI.create( + "http://localhost:%d/api/v1/data".formatted(server.listenPort()))) .GET() .build(), ofString()); @@ -88,14 +76,16 @@ void respondStreamUsesChunkedEncoding() throws Exception { server = OpenApiServer.builder() .spec(spec) - .handlers(asRawHandlers(Map.of("getRoot", streamer, "postData", streamer))) + .handlers(Map.of("get-data", streamer, "post-data", streamer)) .port(0) .build(); var resp = HttpClient.newHttpClient() .send( HttpRequest.newBuilder() - .uri(URI.create("http://localhost:%d/api/v1/".formatted(server.listenPort()))) + .uri( + URI.create( + "http://localhost:%d/api/v1/data".formatted(server.listenPort()))) .GET() .build(), ofString()); diff --git a/src/test/java/com/retailsvc/http/RequestTest.java b/src/test/java/com/retailsvc/http/RequestTest.java index c2c7e56..a7216ac 100644 --- a/src/test/java/com/retailsvc/http/RequestTest.java +++ b/src/test/java/com/retailsvc/http/RequestTest.java @@ -1,12 +1,11 @@ package com.retailsvc.http; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; -import com.retailsvc.http.internal.LegacyRequestAccess; -import com.retailsvc.http.internal.RequestContext; +import com.retailsvc.http.internal.DispatchHandler; +import com.sun.net.httpserver.HttpExchange; import java.util.Map; -import java.util.NoSuchElementException; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; @@ -14,21 +13,29 @@ class RequestTest { @Test void readsBoundContext() throws Exception { - RequestContext ctx = - new RequestContext(new byte[] {1, 2, 3}, Map.of("k", "v"), "get-x", Map.of("id", "42")); + HttpExchange exchange = mock(HttpExchange.class); + Request req = + new Request( + exchange, + new byte[] {1, 2, 3}, + Map.of("k", "v"), + "get-x", + Map.of("id", "42"), + Map.of()); AtomicReference seenBytes = new AtomicReference<>(); AtomicReference seenParsed = new AtomicReference<>(); AtomicReference seenOpId = new AtomicReference<>(); AtomicReference> seenPathParams = new AtomicReference<>(); - ScopedValue.where(LegacyRequestAccess.CONTEXT, ctx) + ScopedValue.where(DispatchHandler.CURRENT, req) .call( () -> { - seenBytes.set(LegacyRequestAccess.bytes()); - seenParsed.set(LegacyRequestAccess.parsed()); - seenOpId.set(LegacyRequestAccess.operationId()); - seenPathParams.set(LegacyRequestAccess.pathParams()); + Request r = DispatchHandler.CURRENT.get(); + seenBytes.set(r.bytes()); + seenParsed.set(r.parsed()); + seenOpId.set(r.operationId()); + seenPathParams.set(r.pathParams()); return null; }); @@ -37,9 +44,4 @@ void readsBoundContext() throws Exception { assertThat(seenOpId.get()).isEqualTo("get-x"); assertThat(seenPathParams.get()).containsEntry("id", "42"); } - - @Test - void readingOutsideScopeThrows() { - assertThrows(NoSuchElementException.class, LegacyRequestAccess::bytes); - } } diff --git a/src/test/java/com/retailsvc/http/ServerBaseTest.java b/src/test/java/com/retailsvc/http/ServerBaseTest.java index 661c48d..fa081e1 100644 --- a/src/test/java/com/retailsvc/http/ServerBaseTest.java +++ b/src/test/java/com/retailsvc/http/ServerBaseTest.java @@ -6,7 +6,6 @@ import com.google.gson.Gson; import com.retailsvc.http.spec.Spec; -import com.sun.net.httpserver.HttpHandler; import java.io.InputStream; import java.net.URI; import java.net.http.HttpClient; @@ -44,7 +43,7 @@ void tearDown() { Optional.ofNullable(server).ifPresent(OpenApiServer::close); } - protected OpenApiServer newServer(Map handlers) { + protected OpenApiServer newServer(Map handlers) { try { server = OpenApiServer.builder().spec(spec).handlers(handlers).port(0).build(); return server; diff --git a/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java b/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java index 5f707b1..be5b692 100644 --- a/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java +++ b/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java @@ -6,8 +6,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import com.retailsvc.http.internal.LegacyRequestAccess; -import com.sun.net.httpserver.HttpHandler; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -23,14 +21,11 @@ class TypeMapperRegistrationTest extends ServerBaseTest { @Test void gsonFallbackIsAutoRegisteredWhenNoJsonMapperConfigured() throws Exception { - HttpHandler echo = - ex -> { - Object parsed = LegacyRequestAccess.parsed(); + RequestHandler echo = + req -> { + Object parsed = req.parsed(); byte[] out = gson.toJson(parsed).getBytes(StandardCharsets.UTF_8); - ex.getResponseHeaders().add("Content-Type", "application/json"); - ex.sendResponseHeaders(200, out.length); - ex.getResponseBody().write(out); - ex.close(); + req.respond(200).contentType("application/json").bytes(out); }; server = OpenApiServer.builder() @@ -69,11 +64,7 @@ public byte[] writeTo(Object v) { return "ignored".getBytes(StandardCharsets.UTF_8); } }; - HttpHandler echo = - ex -> { - ex.sendResponseHeaders(200, -1); - ex.close(); - }; + RequestHandler echo = req -> req.respond(200).empty(); OpenApiServer s = OpenApiServer.builder() .spec(spec) diff --git a/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java b/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java index 8da8b4b..f3842a8 100644 --- a/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java +++ b/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java @@ -1,37 +1,40 @@ package com.retailsvc.http.internal; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import com.retailsvc.http.MissingOperationHandlerException; +import com.retailsvc.http.Request; +import com.retailsvc.http.RequestHandler; import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; class DispatchHandlerTest { - private static void withOperationId( - String operationId, ScopedValue.CallableOp body) throws Exception { - RequestContext ctx = new RequestContext(new byte[0], null, operationId, Map.of()); - ScopedValue.where(LegacyRequestAccess.CONTEXT, ctx).call(body); + private static void withRequest(String operationId, ScopedValue.CallableOp body) + throws Exception { + HttpExchange exchange = mock(HttpExchange.class); + Request req = new Request(exchange, new byte[0], null, operationId, Map.of(), Map.of()); + ScopedValue.where(DispatchHandler.CURRENT, req).call(body); } @Test void invokesRegisteredHandler() throws Exception { - HttpHandler handler = mock(HttpHandler.class); + AtomicBoolean called = new AtomicBoolean(false); + RequestHandler handler = req -> called.set(true); HttpExchange ex = mock(HttpExchange.class); - withOperationId( + withRequest( "get-x", () -> { new DispatchHandler(Map.of("get-x", handler)).handle(ex); return null; }); - // bound op-id is "get-x"; DispatchHandler should look up the registered HttpHandler. - Mockito.verify(handler).handle(Mockito.any()); + assertThat(called.get()).isTrue(); } @Test @@ -41,7 +44,7 @@ void throwsWhenHandlerMissing() { assertThatThrownBy( () -> - withOperationId( + withRequest( "ghost", () -> { d.handle(ex); diff --git a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java index 7f71197..e07d43f 100644 --- a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java +++ b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java @@ -6,6 +6,7 @@ import com.retailsvc.http.MethodNotAllowedException; import com.retailsvc.http.NotFoundException; +import com.retailsvc.http.Request; import com.retailsvc.http.TypeMapper; import com.retailsvc.http.ValidationException; import com.retailsvc.http.spec.HttpMethod; @@ -99,8 +100,9 @@ void successPathBindsRequestContextDuringChain() throws Exception { Filter.Chain chain = mock(Filter.Chain.class); Mockito.doAnswer( inv -> { - seenOpId.set(LegacyRequestAccess.operationId()); - seenPathParams.set(LegacyRequestAccess.pathParams()); + Request req = DispatchHandler.CURRENT.get(); + seenOpId.set(req.operationId()); + seenPathParams.set(req.pathParams()); return null; }) .when(chain) diff --git a/src/test/java/com/retailsvc/http/start/EchoHandler.java b/src/test/java/com/retailsvc/http/start/EchoHandler.java index 43e7067..8413323 100644 --- a/src/test/java/com/retailsvc/http/start/EchoHandler.java +++ b/src/test/java/com/retailsvc/http/start/EchoHandler.java @@ -1,21 +1,20 @@ package com.retailsvc.http.start; -import com.retailsvc.http.internal.LegacyRequestAccess; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; +import com.retailsvc.http.Request; +import com.retailsvc.http.RequestHandler; import java.io.IOException; import java.lang.invoke.MethodHandles; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** Echoes back the request body as a response body */ -public class EchoHandler implements HttpHandler { +public class EchoHandler implements RequestHandler { private static final Logger LOG = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); @Override - public void handle(HttpExchange exchange) throws IOException { - byte[] bytes = LegacyRequestAccess.bytes(); + public void handle(Request request) throws IOException { + byte[] bytes = request.bytes(); if (bytes.length == 0) { LOG.debug("No bytes available to read from the request body"); @@ -26,13 +25,6 @@ public void handle(HttpExchange exchange) throws IOException { String requestBody = new String(bytes); LOG.debug("Request body: {}", requestBody); - try (var os = exchange.getResponseBody(); - exchange) { - - var responseHeaders = exchange.getResponseHeaders(); - responseHeaders.add("Content-Type", "application/json"); - exchange.sendResponseHeaders(200, requestBody.getBytes().length); - os.write(requestBody.getBytes()); - } + request.respond(200).contentType("application/json").bytes(requestBody.getBytes()); } } diff --git a/src/test/java/com/retailsvc/http/start/FormEchoHandler.java b/src/test/java/com/retailsvc/http/start/FormEchoHandler.java index f4f95d7..8809f78 100644 --- a/src/test/java/com/retailsvc/http/start/FormEchoHandler.java +++ b/src/test/java/com/retailsvc/http/start/FormEchoHandler.java @@ -1,24 +1,17 @@ package com.retailsvc.http.start; -import static java.net.HttpURLConnection.HTTP_OK; - -import com.retailsvc.http.internal.LegacyRequestAccess; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; +import com.retailsvc.http.Request; +import com.retailsvc.http.RequestHandler; import java.io.IOException; import java.nio.charset.StandardCharsets; /** Echoes the parsed form body to the response as Map#toString. */ -public class FormEchoHandler implements HttpHandler { +public class FormEchoHandler implements RequestHandler { @Override - public void handle(HttpExchange exchange) throws IOException { - Object parsed = LegacyRequestAccess.parsed(); + public void handle(Request request) throws IOException { + Object parsed = request.parsed(); byte[] body = String.valueOf(parsed).getBytes(StandardCharsets.UTF_8); - try (exchange) { - exchange.getResponseHeaders().add("Content-Type", "text/plain; charset=utf-8"); - exchange.sendResponseHeaders(HTTP_OK, body.length); - exchange.getResponseBody().write(body); - } + request.respond(200).contentType("text/plain; charset=utf-8").bytes(body); } } diff --git a/src/test/java/com/retailsvc/http/start/GetDataHandler.java b/src/test/java/com/retailsvc/http/start/GetDataHandler.java index fe4f13d..2bbac47 100644 --- a/src/test/java/com/retailsvc/http/start/GetDataHandler.java +++ b/src/test/java/com/retailsvc/http/start/GetDataHandler.java @@ -1,37 +1,26 @@ package com.retailsvc.http.start; -import static java.net.HttpURLConnection.HTTP_OK; - -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; +import com.retailsvc.http.Request; +import com.retailsvc.http.RequestHandler; import java.io.IOException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class GetDataHandler implements HttpHandler { +public class GetDataHandler implements RequestHandler { private static final Logger LOG = LoggerFactory.getLogger(GetDataHandler.class); @Override - public void handle(HttpExchange exchange) throws IOException { + public void handle(Request request) throws IOException { LOG.debug("GET /data"); - try (exchange) { - byte[] bytes = - """ - { - "id": "some-id" - }\ - """ - .getBytes(); - try (var os = exchange.getResponseBody()) { - var responseHeaders = exchange.getResponseHeaders(); - responseHeaders.add("content-type", "application/json"); - - exchange.sendResponseHeaders(HTTP_OK, bytes.length); - - os.write(bytes); - } - } + byte[] bytes = + """ + { + "id": "some-id" + }\ + """ + .getBytes(); + request.respond(200).contentType("application/json").bytes(bytes); } } diff --git a/src/test/java/com/retailsvc/http/start/ParamHandler.java b/src/test/java/com/retailsvc/http/start/ParamHandler.java index 9633644..fd0ba5c 100644 --- a/src/test/java/com/retailsvc/http/start/ParamHandler.java +++ b/src/test/java/com/retailsvc/http/start/ParamHandler.java @@ -1,25 +1,19 @@ package com.retailsvc.http.start; -import static java.net.HttpURLConnection.HTTP_OK; - -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; +import com.retailsvc.http.Request; +import com.retailsvc.http.RequestHandler; import java.io.IOException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class ParamHandler implements HttpHandler { +public class ParamHandler implements RequestHandler { private static final Logger LOG = LoggerFactory.getLogger(ParamHandler.class); @Override - public void handle(HttpExchange exchange) throws IOException { + public void handle(Request request) throws IOException { LOG.debug("GET /params"); - try (exchange) { - // -1 = no response body. Passing 0 would trigger chunked transfer encoding with zero chunks, - // which is technically non-conformant for an empty 200. - exchange.sendResponseHeaders(HTTP_OK, -1); - } + request.respond(200).empty(); } } diff --git a/src/test/java/com/retailsvc/http/start/PostDataHandler.java b/src/test/java/com/retailsvc/http/start/PostDataHandler.java index 783d526..ae82e5e 100644 --- a/src/test/java/com/retailsvc/http/start/PostDataHandler.java +++ b/src/test/java/com/retailsvc/http/start/PostDataHandler.java @@ -1,22 +1,21 @@ package com.retailsvc.http.start; -import com.retailsvc.http.internal.LegacyRequestAccess; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; +import com.retailsvc.http.Request; +import com.retailsvc.http.RequestHandler; import java.io.IOException; import java.lang.invoke.MethodHandles; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class PostDataHandler implements HttpHandler { +public class PostDataHandler implements RequestHandler { private static final Logger LOG = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); @Override - public void handle(HttpExchange exchange) throws IOException { + public void handle(Request request) throws IOException { LOG.debug("POST /data"); - byte[] bytes = LegacyRequestAccess.bytes(); + byte[] bytes = request.bytes(); if (bytes.length == 0) { LOG.debug("No bytes available to read from the request body"); @@ -27,12 +26,6 @@ public void handle(HttpExchange exchange) throws IOException { String requestBody = new String(bytes); LOG.debug("Request body: {}", requestBody); - try (exchange; - var os = exchange.getResponseBody()) { - exchange.getResponseHeaders().add("Content-Type", "application/json"); - exchange.sendResponseHeaders(200, requestBody.length()); - - os.write(requestBody.getBytes()); - } + request.respond(200).contentType("application/json").bytes(requestBody.getBytes()); } } diff --git a/src/test/java/com/retailsvc/http/start/PostListObjectsHandler.java b/src/test/java/com/retailsvc/http/start/PostListObjectsHandler.java index e31a6f4..a0c4c75 100644 --- a/src/test/java/com/retailsvc/http/start/PostListObjectsHandler.java +++ b/src/test/java/com/retailsvc/http/start/PostListObjectsHandler.java @@ -1,22 +1,21 @@ package com.retailsvc.http.start; -import com.retailsvc.http.internal.LegacyRequestAccess; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; +import com.retailsvc.http.Request; +import com.retailsvc.http.RequestHandler; import java.io.IOException; import java.lang.invoke.MethodHandles; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class PostListObjectsHandler implements HttpHandler { +public class PostListObjectsHandler implements RequestHandler { private static final Logger LOG = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); @Override - public void handle(HttpExchange exchange) throws IOException { + public void handle(Request request) throws IOException { LOG.debug("POST /list/objects"); - byte[] bytes = LegacyRequestAccess.bytes(); + byte[] bytes = request.bytes(); if (bytes.length == 0) { LOG.debug("No bytes available to read from the request body"); @@ -27,11 +26,6 @@ public void handle(HttpExchange exchange) throws IOException { String requestBody = new String(bytes); LOG.debug("Request body: {}", requestBody); - try (var os = exchange.getResponseBody(); - exchange) { - exchange.getResponseHeaders().add("Content-Type", "application/json"); - exchange.sendResponseHeaders(200, requestBody.getBytes().length); - os.write(requestBody.getBytes()); - } + request.respond(200).contentType("application/json").bytes(requestBody.getBytes()); } } diff --git a/src/test/java/com/retailsvc/http/start/ServerLauncher.java b/src/test/java/com/retailsvc/http/start/ServerLauncher.java index 4e83c1f..614d5b8 100644 --- a/src/test/java/com/retailsvc/http/start/ServerLauncher.java +++ b/src/test/java/com/retailsvc/http/start/ServerLauncher.java @@ -3,8 +3,8 @@ import com.retailsvc.http.ExceptionHandler; import com.retailsvc.http.Handlers; import com.retailsvc.http.OpenApiServer; +import com.retailsvc.http.RequestHandler; import com.retailsvc.http.spec.Spec; -import com.sun.net.httpserver.HttpHandler; import java.io.IOException; import java.io.InputStream; import java.util.HashMap; @@ -30,7 +30,7 @@ public ServerLauncher() throws IOException { } Spec spec = Spec.from(raw); - Map handlers = new HashMap<>(); + Map handlers = new HashMap<>(); handlers.put("get-data", new GetDataHandler()); handlers.put("post-data", new PostDataHandler()); handlers.put("post-list-objects", new PostListObjectsHandler()); diff --git a/src/test/java/com/retailsvc/http/start/TextEchoHandler.java b/src/test/java/com/retailsvc/http/start/TextEchoHandler.java index fc72978..0880f90 100644 --- a/src/test/java/com/retailsvc/http/start/TextEchoHandler.java +++ b/src/test/java/com/retailsvc/http/start/TextEchoHandler.java @@ -1,24 +1,17 @@ package com.retailsvc.http.start; -import static java.net.HttpURLConnection.HTTP_OK; - -import com.retailsvc.http.internal.LegacyRequestAccess; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; +import com.retailsvc.http.Request; +import com.retailsvc.http.RequestHandler; import java.io.IOException; import java.nio.charset.StandardCharsets; /** Echoes the parsed text/plain body back to the response. */ -public class TextEchoHandler implements HttpHandler { +public class TextEchoHandler implements RequestHandler { @Override - public void handle(HttpExchange exchange) throws IOException { - String parsed = (String) LegacyRequestAccess.parsed(); + public void handle(Request request) throws IOException { + String parsed = (String) request.parsed(); byte[] body = parsed.getBytes(StandardCharsets.UTF_8); - try (exchange) { - exchange.getResponseHeaders().add("Content-Type", "text/plain; charset=utf-8"); - exchange.sendResponseHeaders(HTTP_OK, body.length); - exchange.getResponseBody().write(body); - } + request.respond(200).contentType("text/plain; charset=utf-8").bytes(body); } } From 59c542d576327206494ed184c4fe8a8a2fbe1bb5 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 16:13:50 +0200 Subject: [PATCH 21/50] refactor: Use HttpURLConnection.HTTP_OK in GetDataHandler --- src/test/java/com/retailsvc/http/start/GetDataHandler.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/retailsvc/http/start/GetDataHandler.java b/src/test/java/com/retailsvc/http/start/GetDataHandler.java index 2bbac47..505df5d 100644 --- a/src/test/java/com/retailsvc/http/start/GetDataHandler.java +++ b/src/test/java/com/retailsvc/http/start/GetDataHandler.java @@ -3,6 +3,7 @@ import com.retailsvc.http.Request; import com.retailsvc.http.RequestHandler; import java.io.IOException; +import java.net.HttpURLConnection; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -21,6 +22,6 @@ public void handle(Request request) throws IOException { }\ """ .getBytes(); - request.respond(200).contentType("application/json").bytes(bytes); + request.respond(HttpURLConnection.HTTP_OK).contentType("application/json").bytes(bytes); } } From e4c7516712bc1466e1c2ac2e0513dd74f5622972 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 16:15:22 +0200 Subject: [PATCH 22/50] refactor: Remove LegacyRequestAccess and RequestContext --- .../http/internal/LegacyRequestAccess.java | 50 ---------- .../http/internal/RequestContext.java | 54 ----------- .../http/internal/RequestContextTest.java | 96 ------------------- 3 files changed, 200 deletions(-) delete mode 100644 src/main/java/com/retailsvc/http/internal/LegacyRequestAccess.java delete mode 100644 src/main/java/com/retailsvc/http/internal/RequestContext.java delete mode 100644 src/test/java/com/retailsvc/http/internal/RequestContextTest.java diff --git a/src/main/java/com/retailsvc/http/internal/LegacyRequestAccess.java b/src/main/java/com/retailsvc/http/internal/LegacyRequestAccess.java deleted file mode 100644 index cbe023d..0000000 --- a/src/main/java/com/retailsvc/http/internal/LegacyRequestAccess.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.retailsvc.http.internal; - -import java.util.Map; - -/** - * Static accessors for per-request state populated by the request-preparation filter. - * - *

The state is bound to a {@link ScopedValue} for the duration of the request rather than stored - * on the {@code HttpExchange}, because {@code HttpExchange.setAttribute} writes to a context-shared - * map and would race across concurrent requests. - * - *

If a handler dispatches work to a non-structured executor (i.e. not a {@code - * StructuredTaskScope}-managed thread), it must capture the values it needs before submitting — the - * {@link ScopedValue} is not visible from arbitrary worker threads. - * - *

Temporary scaffolding bridging the static-accessor era to the per-request {@code Request} - * handle. Removed in a follow-up task once the new handler API replaces all consumers. - */ -public final class LegacyRequestAccess { - - /** Bound by {@code RequestPreparationFilter} for the duration of each request. */ - public static final ScopedValue CONTEXT = ScopedValue.newInstance(); - - private LegacyRequestAccess() {} - - /** - * Returns the full per-request context. Use this when a handler reads more than one field — every - * call to {@link #bytes()}, {@link #parsed()}, {@link #operationId()}, or {@link #pathParams()} - * walks the JDK's scope chain independently, so reading via {@code current()} once is cheaper. - */ - public static RequestContext current() { - return CONTEXT.get(); - } - - public static byte[] bytes() { - return CONTEXT.get().body(); - } - - public static Object parsed() { - return CONTEXT.get().parsedBody(); - } - - public static String operationId() { - return CONTEXT.get().operationId(); - } - - public static Map pathParams() { - return CONTEXT.get().pathParameters(); - } -} diff --git a/src/main/java/com/retailsvc/http/internal/RequestContext.java b/src/main/java/com/retailsvc/http/internal/RequestContext.java deleted file mode 100644 index 581f47f..0000000 --- a/src/main/java/com/retailsvc/http/internal/RequestContext.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.retailsvc.http.internal; - -import java.util.Arrays; -import java.util.Map; -import java.util.Objects; - -/** - * Immutable per-request data populated by {@link RequestPreparationFilter} and read by handlers via - * {@link LegacyRequestAccess}. Bound to a {@link ScopedValue} for the duration of a single request - * — never written to the {@code HttpExchange}'s context-shared attribute map. - * - *

{@code equals}, {@code hashCode}, and {@code toString} are overridden because the record - * carries a {@code byte[]} component: the auto-generated implementations use reference equality on - * arrays, which would treat structurally-equal contexts as different. - */ -public record RequestContext( - byte[] body, Object parsedBody, String operationId, Map pathParameters) { - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - return o - instanceof - RequestContext( - byte[] otherBody, - Object otherParsedBody, - String otherOperationId, - Map otherPathParameters) - && Arrays.equals(body, otherBody) - && Objects.equals(parsedBody, otherParsedBody) - && Objects.equals(operationId, otherOperationId) - && Objects.equals(pathParameters, otherPathParameters); - } - - @Override - public int hashCode() { - return Objects.hash(Arrays.hashCode(body), parsedBody, operationId, pathParameters); - } - - @Override - public String toString() { - return "RequestContext[body=byte[" - + (body == null ? 0 : body.length) - + "], parsedBody=" - + parsedBody - + ", operationId=" - + operationId - + ", pathParameters=" - + pathParameters - + "]"; - } -} diff --git a/src/test/java/com/retailsvc/http/internal/RequestContextTest.java b/src/test/java/com/retailsvc/http/internal/RequestContextTest.java deleted file mode 100644 index 7ebe893..0000000 --- a/src/test/java/com/retailsvc/http/internal/RequestContextTest.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.retailsvc.http.internal; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.util.Map; -import org.junit.jupiter.api.Test; - -class RequestContextTest { - - private static final byte[] BODY_A = {1, 2, 3}; - private static final byte[] BODY_A_COPY = {1, 2, 3}; - private static final byte[] BODY_B = {1, 2, 4}; - - private RequestContext context(byte[] body, Object parsed, String opId, Map pp) { - return new RequestContext(body, parsed, opId, pp); - } - - @Test - void equalsIsReflexive() { - RequestContext c = context(BODY_A, "p", "op", Map.of("k", "v")); - assertThat(c).isEqualTo(c); - } - - @Test - void equalsTreatsByteArraysStructurally() { - RequestContext a = context(BODY_A, "p", "op", Map.of("k", "v")); - RequestContext b = context(BODY_A_COPY, "p", "op", Map.of("k", "v")); - assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); - } - - @Test - void equalsRejectsDifferentBytes() { - RequestContext a = context(BODY_A, "p", "op", Map.of("k", "v")); - RequestContext b = context(BODY_B, "p", "op", Map.of("k", "v")); - assertThat(a).isNotEqualTo(b); - } - - @Test - void equalsRejectsDifferentParsedBody() { - RequestContext a = context(BODY_A, "p", "op", Map.of("k", "v")); - RequestContext b = context(BODY_A, "different", "op", Map.of("k", "v")); - assertThat(a).isNotEqualTo(b); - } - - @Test - void equalsRejectsDifferentOperationId() { - RequestContext a = context(BODY_A, "p", "op", Map.of("k", "v")); - RequestContext b = context(BODY_A, "p", "other-op", Map.of("k", "v")); - assertThat(a).isNotEqualTo(b); - } - - @Test - void equalsRejectsDifferentPathParameters() { - RequestContext a = context(BODY_A, "p", "op", Map.of("k", "v")); - RequestContext b = context(BODY_A, "p", "op", Map.of("k", "other")); - assertThat(a).isNotEqualTo(b); - } - - @Test - void equalsRejectsNullAndOtherTypes() { - RequestContext c = context(BODY_A, "p", "op", Map.of()); - assertThat(c).isNotEqualTo(null).isNotEqualTo("not a context"); - } - - @Test - void equalsHandlesNullParsedBody() { - RequestContext a = context(BODY_A, null, "op", Map.of()); - RequestContext b = context(BODY_A_COPY, null, "op", Map.of()); - assertThat(a).isEqualTo(b); - } - - @Test - void hashCodeIsStableAcrossInvocations() { - RequestContext c = context(BODY_A, "p", "op", Map.of("k", "v")); - int first = c.hashCode(); - int second = c.hashCode(); - assertThat(first).isEqualTo(second); - } - - @Test - void toStringSummarisesBodyByLength() { - RequestContext c = context(BODY_A, "parsed", "get-x", Map.of("id", "42")); - assertThat(c.toString()) - .contains("body=byte[3]") - .contains("parsedBody=parsed") - .contains("operationId=get-x") - .contains("pathParameters={id=42}") - .doesNotContain("[B@"); - } - - @Test - void toStringHandlesNullBody() { - RequestContext c = context(null, null, "op", Map.of()); - assertThat(c.toString()).contains("body=byte[0]"); - } -} From bac0a686b4731ae1882cc4f675f06dd03a2c397c Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 16:25:29 +0200 Subject: [PATCH 23/50] docs: Update README for TypeMapper and RequestHandler --- README.md | 119 +++++++++++++++++++++++++++++------------------------- 1 file changed, 64 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 94cd0d4..d82db42 100644 --- a/README.md +++ b/README.md @@ -26,45 +26,27 @@ It is designed to be simple to use while providing the essential features needed ### Basic Usage 1. Create an OpenAPI specification file named `openapi.json` in your project resources. -2. Define your HTTP handlers by implementing the `HttpHandler` interface: +2. Define your handlers using the `RequestHandler` functional interface: ``` java -public class GetDataHandler implements HttpHandler { - @Override - public void handle(HttpExchange exchange) throws IOException { - try (exchange) { - byte[] bytes = """ - { - "id": "some-id" - }""".getBytes(); - - var responseHeaders = exchange.getResponseHeaders(); - responseHeaders.add("content-type", "application/json"); - - exchange.sendResponseHeaders(HTTP_OK, bytes.length); - - try (var os = exchange.getResponseBody()) { - os.write(bytes); - } - } - } -} +// Inline lambda — returns JSON using the built-in Gson mapper. +RequestHandler getDataHandler = req -> + req.respond(200).json(Map.of("id", "some-id")); -public class PostDataHandler implements HttpHandler { +// Class form — reads raw bytes or the pre-parsed body object. +public class PostDataHandler implements RequestHandler { @Override - public void handle(HttpExchange exchange) throws IOException { - try (exchange) { - // Access the raw request body bytes. - byte[] body = Request.bytes(exchange); - // Or get the already-parsed object (Map or List) produced by your JsonMapper. - Object parsed = Request.parsed(exchange); - - exchange.sendResponseHeaders(HTTP_OK, -1); - } + public void handle(Request request) throws IOException { + // Access the raw request body bytes. + byte[] body = request.bytes(); + // Or get the already-parsed object (Map / List) produced by the registered TypeMapper. + Object parsed = request.parsed(); + + request.respond(200).json(parsed); } } ``` -3. Initialize the server (using Gson in this example): +3. Initialize the server: ``` java public class YourServerLauncher { public static void main(String[] args) throws Exception { @@ -75,17 +57,13 @@ public class YourServerLauncher { Map raw = (Map) gson.fromJson(text, Map.class); Spec spec = Spec.from(raw); - // Body parser. Returns a Map for objects, List for arrays. - JsonMapper mapper = body -> gson.fromJson(new String(body), Object.class); - // Handlers by operationId. - Map handlers = new HashMap<>(); - handlers.put("get-data", new GetDataHandler()); + Map handlers = new HashMap<>(); + handlers.put("get-data", getDataHandler); handlers.put("post-data", new PostDataHandler()); var server = OpenApiServer.builder() .spec(spec) - .jsonMapper(mapper) .handlers(handlers) .exceptionHandler(Handlers.defaultExceptionHandler()) .build(); @@ -100,13 +78,51 @@ Map raw = new Yaml().load(Files.newInputStream(Path.of("openapi. ``` The rest is identical. +### JSON mapping + +The library ships an internal `GsonJsonMapper` that is auto-registered for `application/json` when Gson is on the classpath and no user-supplied JSON mapper has been registered. It: + +- Returns JSON integers as `Long` and fractional numbers as `Double`. +- Writes JSR-310 types (`Instant`, `OffsetDateTime`, `ZonedDateTime`, `LocalDateTime`, `LocalDate`, `LocalTime`) as ISO-8601 strings. + +For non-ISO date formats, custom naming strategies, or other custom serialization, register your own `TypeMapper`: + +``` java +var server = OpenApiServer.builder() + .spec(spec) + .bodyMapper("application/json", new MyCustomJsonMapper()) + .handlers(handlers) + .build(); +``` + +If Gson is not on the classpath and no `application/json` mapper is registered, `build()` throws `IllegalStateException`. + +### Body parsers and response writers + +`TypeMapper` is the per-media-type read/write contract: + +``` java +public interface TypeMapper { + Object readFrom(byte[] body, String contentTypeHeader); + byte[] writeTo(Object value); +} +``` + +Register a custom mapper for any media type via `Builder.bodyMapper(mediaType, mapper)`. Built-in defaults: + +- `application/x-www-form-urlencoded` — read-only. Produces `Map`. A single value is a `String`; repeated keys produce a `List`. +- `text/plain` — read and write. Produces a decoded `String`; writes via `String.getBytes()`. +- `application/json` — auto-registered when Gson is on the classpath (see above). + +User-supplied mappers take precedence over built-in defaults, so you can override any of the above. + ### Request body content types -The server reads `requestBody.content` from the spec and selects a parser by the request's media type (the bare `type/subtype` from `Content-Type`, e.g. `application/json`; lookup is case-insensitive): +The server reads `requestBody.content` from the spec and selects a mapper by the request's media type (the bare `type/subtype` from `Content-Type`, e.g. `application/json`; lookup is case-insensitive): | Content type | Parser | Coercion | | ------------------------------------- | ---------------------------------------------------------------------------- | -------- | -| `application/json` | Caller-supplied `JsonMapper` | No — strict against the schema | +| `application/json` | `GsonJsonMapper` (auto) or caller-supplied `TypeMapper` | No — strict against the schema | | `application/x-www-form-urlencoded` | Built-in. `Map`. A single value is a `String`; repeated keys produce a `List`. After coercion the element type tracks the schema (e.g. an `integer` array yields `List`). | Yes — field values coerced to the property schema type (integer / number / boolean / array of those) | | `text/plain` | Built-in. Decoded `String` | No — schema should be `type: string` | @@ -159,7 +175,6 @@ to OpenAPI parameter / body validation. ``` java var server = OpenApiServer.builder() .spec(spec) - .jsonMapper(mapper) .handlers(handlers) .addHandler("/alive", Handlers.aliveHandler()) .addHandler("/schemas/v1/openapi.yaml", @@ -189,7 +204,6 @@ try-with-resources) via the builder: ```java try (var server = OpenApiServer.builder() .spec(spec) - .jsonMapper(mapper) .handlers(handlers) .shutdownTimeoutSeconds(5) // close() drains up to 5s; default is 0 .build()) { @@ -202,20 +216,15 @@ try (var server = OpenApiServer.builder() ## Features - OpenAPI specification support -- Automatic request body parsing for JSON arrays and objects -- Custom HTTP handler support -- Built on Java's native `HttpServer` with Thread-Per-Request behaviour using Virtual Threads. -- Custom integration for JSON serialization/deserialization +- Automatic request body parsing and response writing per media type via `TypeMapper` +- `RequestHandler` functional interface — a single `handle(Request)` method replaces raw `HttpExchange` manipulation +- Fluent `ResponseBuilder` via `request.respond(status)` with terminals: `empty()`, `bytes()`, `text()`, `json()`, `body()`, `stream()` +- Built-in `GsonJsonMapper` auto-registered when Gson is on the classpath (no explicit wiring needed) +- Built on Java's native `HttpServer` with Thread-Per-Request behaviour using Virtual Threads ## Handler Registration -Handlers are registered using string keys that correspond to your OpenAPI operation IDs. - - -## JSON Mapping -The library uses a flexible JSON mapping system that automatically detects and parses (using a mapper of choice): -- JSON arrays (`[...]`) -- JSON objects (`{...}`) +Handlers are registered in a `Map` keyed by OpenAPI `operationId`. ## Local development @@ -235,7 +244,7 @@ A few things to know: - **Single-process model.** No horizontal scaling primitives are bundled; run multiple instances behind a load balancer for production scale. - **JDK HttpServer is the throughput ceiling.** It's documented as a low-throughput / dev-test server. If you need to go materially above the rates above, deploy the same filter/validator/router stack on Jetty, Helidon Níma, or Netty — the spec and validation code is server-agnostic. -- **Per-request state uses `ScopedValue`** (Java 25, JEP 506), not `HttpExchange.setAttribute`. This matters if a handler offloads work to an executor that's not a `StructuredTaskScope`-managed child thread: the `ScopedValue` is not visible there, so the handler must capture the values it needs (e.g. `byte[] body = Request.bytes();`) before submitting. -- **`HttpExchange.sendResponseHeaders(rCode, length)` gotcha.** When a handler has no response body, pass `-1` (`Content-Length: 0`, no body); passing `0` produces a chunked response with zero chunks, which is technically non-conformant. +- **Per-request state uses `ScopedValue`** (Java 25, JEP 506). This matters if a handler offloads work to an executor that's not a `StructuredTaskScope`-managed child thread: the `ScopedValue` is not visible there, so the handler must capture the values it needs (e.g. `byte[] body = request.bytes();`) before submitting. +- **Empty responses must use `request.respond(status).empty()`**, which sends `responseLength = -1` (`Content-Length: 0`, no body). Passing `0` produces a chunked response with zero chunks, which is technically non-conformant. ## Known limitations or missing features From 6766e30de23d26b8d00c84a897cd8920faf089eb Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 13 May 2026 16:35:17 +0200 Subject: [PATCH 24/50] refactor: Use static import for assertThatThrownBy in FormUrlEncodedParserTest --- .../com/retailsvc/http/internal/FormUrlEncodedParserTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/retailsvc/http/internal/FormUrlEncodedParserTest.java b/src/test/java/com/retailsvc/http/internal/FormUrlEncodedParserTest.java index a286a67..d9246ff 100644 --- a/src/test/java/com/retailsvc/http/internal/FormUrlEncodedParserTest.java +++ b/src/test/java/com/retailsvc/http/internal/FormUrlEncodedParserTest.java @@ -1,6 +1,7 @@ package com.retailsvc.http.internal; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.retailsvc.http.spec.schema.ArraySchema; import com.retailsvc.http.spec.schema.IntegerSchema; @@ -108,7 +109,7 @@ void coercionFailureThrowsValidationExceptionAtPropertyPointer() { IntegerSchema intSchema = anIntegerSchema(); ObjectSchema bodySchema = anObjectSchema(Map.of("age", intSchema)); - org.assertj.core.api.Assertions.assertThatThrownBy( + assertThatThrownBy( () -> FormBodyCoercion.coerce( parser.parse("age=abc".getBytes(StandardCharsets.UTF_8), null), bodySchema)) From 11936f5010b52252c56d83c7234acb3dd4735dc1 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 14 May 2026 09:27:51 +0200 Subject: [PATCH 25/50] fix: Close HttpExchange after sending one-shot or streaming responses --- .../http/internal/DefaultResponseBuilder.java | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/retailsvc/http/internal/DefaultResponseBuilder.java b/src/main/java/com/retailsvc/http/internal/DefaultResponseBuilder.java index d69dff7..6bee93f 100644 --- a/src/main/java/com/retailsvc/http/internal/DefaultResponseBuilder.java +++ b/src/main/java/com/retailsvc/http/internal/DefaultResponseBuilder.java @@ -3,6 +3,7 @@ import com.retailsvc.http.ResponseBuilder; import com.retailsvc.http.TypeMapper; import com.sun.net.httpserver.HttpExchange; +import java.io.FilterOutputStream; import java.io.IOException; import java.io.OutputStream; import java.nio.charset.StandardCharsets; @@ -41,18 +42,22 @@ public ResponseBuilder contentType(String contentType) { @Override public void empty() throws IOException { terminate(); - applyHeaders(); - exchange.sendResponseHeaders(status, -1); + try (exchange) { + applyHeaders(); + exchange.sendResponseHeaders(status, -1); + } } @Override public void bytes(byte[] body) throws IOException { terminate(); - applyHeaders(); - exchange.sendResponseHeaders(status, body.length == 0 ? -1 : body.length); - if (body.length > 0) { - try (OutputStream out = exchange.getResponseBody()) { - out.write(body); + try (exchange) { + applyHeaders(); + exchange.sendResponseHeaders(status, body.length == 0 ? -1 : body.length); + if (body.length > 0) { + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } } } } @@ -83,7 +88,7 @@ public OutputStream stream() throws IOException { terminate(); applyHeaders(); exchange.sendResponseHeaders(status, 0); - return exchange.getResponseBody(); + return closingExchange(exchange.getResponseBody()); } @Override @@ -94,7 +99,27 @@ public OutputStream stream(long length) throws IOException { terminate(); applyHeaders(); exchange.sendResponseHeaders(status, length); - return exchange.getResponseBody(); + return closingExchange(exchange.getResponseBody()); + } + + /** + * Wraps the response body so the underlying {@link HttpExchange} is closed once the caller closes + * the stream. + */ + private OutputStream closingExchange(OutputStream body) { + return new FilterOutputStream(body) { + @Override + public void write(byte[] b, int off, int len) throws IOException { + out.write(b, off, len); + } + + @Override + public void close() throws IOException { + try (exchange) { + super.close(); + } + } + }; } private void terminate() { From f124828af7a68b6150863c353f7d1c49aa05a43e Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 14 May 2026 09:29:52 +0200 Subject: [PATCH 26/50] fix: Enforce one-terminal-per-Request across respond() calls --- src/main/java/com/retailsvc/http/Request.java | 6 +++++- .../http/internal/DefaultResponseBuilder.java | 5 ++++- .../java/com/retailsvc/http/RequestTest.java | 16 ++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/retailsvc/http/Request.java b/src/main/java/com/retailsvc/http/Request.java index 4d5810a..5d32c9d 100644 --- a/src/main/java/com/retailsvc/http/Request.java +++ b/src/main/java/com/retailsvc/http/Request.java @@ -16,6 +16,7 @@ public final class Request { private final String operationId; private final Map pathParameters; private final Map bodyMappers; + private boolean responseSent; public Request( HttpExchange exchange, @@ -53,6 +54,9 @@ public String header(String name) { } public ResponseBuilder respond(int status) { - return new DefaultResponseBuilder(exchange, status, bodyMappers); + if (responseSent) { + throw new IllegalStateException("Response already sent"); + } + return new DefaultResponseBuilder(exchange, status, bodyMappers, () -> responseSent = true); } } diff --git a/src/main/java/com/retailsvc/http/internal/DefaultResponseBuilder.java b/src/main/java/com/retailsvc/http/internal/DefaultResponseBuilder.java index 6bee93f..e2d53db 100644 --- a/src/main/java/com/retailsvc/http/internal/DefaultResponseBuilder.java +++ b/src/main/java/com/retailsvc/http/internal/DefaultResponseBuilder.java @@ -17,14 +17,16 @@ public final class DefaultResponseBuilder implements ResponseBuilder { private final HttpExchange exchange; private final int status; private final Map mappers; + private final Runnable onTerminated; private final Map pendingHeaders = new LinkedHashMap<>(); private boolean terminated; public DefaultResponseBuilder( - HttpExchange exchange, int status, Map mappers) { + HttpExchange exchange, int status, Map mappers, Runnable onTerminated) { this.exchange = exchange; this.status = status; this.mappers = mappers; + this.onTerminated = onTerminated; } @Override @@ -125,6 +127,7 @@ public void close() throws IOException { private void terminate() { checkNotTerminated(); terminated = true; + onTerminated.run(); } private void checkNotTerminated() { diff --git a/src/test/java/com/retailsvc/http/RequestTest.java b/src/test/java/com/retailsvc/http/RequestTest.java index a7216ac..a598098 100644 --- a/src/test/java/com/retailsvc/http/RequestTest.java +++ b/src/test/java/com/retailsvc/http/RequestTest.java @@ -1,9 +1,12 @@ package com.retailsvc.http; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import com.retailsvc.http.internal.DispatchHandler; +import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpExchange; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; @@ -44,4 +47,17 @@ void readsBoundContext() throws Exception { assertThat(seenOpId.get()).isEqualTo("get-x"); assertThat(seenPathParams.get()).containsEntry("id", "42"); } + + @Test + void respondAfterTerminalThrows() throws Exception { + HttpExchange exchange = mock(HttpExchange.class); + when(exchange.getResponseHeaders()).thenReturn(new Headers()); + Request req = new Request(exchange, new byte[0], null, "op", Map.of(), Map.of()); + + req.respond(204).empty(); + + assertThatThrownBy(() -> req.respond(200)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("already sent"); + } } From ad6ec3f51859d85706bde9d0f3383eb291fae0ff Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 14 May 2026 09:34:47 +0200 Subject: [PATCH 27/50] refactor: Address SonarQube findings on TypeMapper/RequestHandler change --- .../internal/RequestPreparationFilter.java | 8 +- .../http/internal/gson/GsonJsonMapper.java | 75 ++++++++++++------- .../com/retailsvc/http/OpenApiServerTest.java | 15 ++-- .../http/TypeMapperRegistrationTest.java | 22 +++++- .../http/internal/FormTypeMapperTest.java | 4 +- .../internal/gson/GsonJsonMapperTest.java | 9 ++- 6 files changed, 85 insertions(+), 48 deletions(-) diff --git a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java index 12ad1f6..af608c9 100644 --- a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java +++ b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java @@ -23,6 +23,8 @@ public final class RequestPreparationFilter extends Filter { + private static final String BODY_POINTER = "/body"; + private final Spec spec; private final Router router; private final Validator validator; @@ -126,7 +128,7 @@ private Object validateAndParseBody(HttpExchange exchange, Operation op, byte[] if (body.length == 0) { if (rb.get().required()) { throw new ValidationException( - new ValidationError("/body", "required", "request body is required", null)); + new ValidationError(BODY_POINTER, "required", "request body is required", null)); } return null; } @@ -136,13 +138,13 @@ private Object validateAndParseBody(HttpExchange exchange, Operation op, byte[] if (mt == null) { throw new ValidationException( new ValidationError( - "/body", "content-type", "unsupported content type: " + mediaType, null)); + BODY_POINTER, "content-type", "unsupported content type: " + mediaType, null)); } TypeMapper mapper = bodyMappers.get(mediaType); if (mapper == null) { throw new ValidationException( new ValidationError( - "/body", "content-type", "unsupported content type: " + mediaType, null)); + BODY_POINTER, "content-type", "unsupported content type: " + mediaType, null)); } Object parsed = mapper.readFrom(body, header); if (mediaType.equals("application/x-www-form-urlencoded") && parsed instanceof Map map) { diff --git a/src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java b/src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java index 8c17ff1..f723aab 100644 --- a/src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java +++ b/src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java @@ -70,39 +70,56 @@ public byte[] writeTo(Object value) { private static Object toJavaObject(JsonElement element) { if (element == null || element instanceof JsonNull) { return null; - } else if (element instanceof JsonObject obj) { - Map map = new LinkedHashMap<>(); - for (Map.Entry entry : obj.entrySet()) { - map.put(entry.getKey(), toJavaObject(entry.getValue())); - } - return map; - } else if (element instanceof JsonArray arr) { - List list = new ArrayList<>(arr.size()); - for (JsonElement item : arr) { - list.add(toJavaObject(item)); - } - return list; - } else if (element instanceof JsonPrimitive prim) { - if (prim.isBoolean()) { - return prim.getAsBoolean(); - } else if (prim.isString()) { - return prim.getAsString(); - } else { - // Number - String raw = prim.getAsString(); - if (raw.indexOf('.') < 0 && raw.indexOf('e') < 0 && raw.indexOf('E') < 0) { - try { - return Long.parseLong(raw); - } catch (NumberFormatException _) { - // falls through to Double for out-of-range integers - } - } - return Double.parseDouble(raw); - } + } + if (element instanceof JsonObject obj) { + return toMap(obj); + } + if (element instanceof JsonArray arr) { + return toList(arr); + } + if (element instanceof JsonPrimitive prim) { + return toPrimitive(prim); } throw new IllegalStateException("Unexpected JsonElement type: " + element.getClass()); } + private static Map toMap(JsonObject obj) { + Map map = new LinkedHashMap<>(); + for (Map.Entry entry : obj.entrySet()) { + map.put(entry.getKey(), toJavaObject(entry.getValue())); + } + return map; + } + + private static List toList(JsonArray arr) { + List list = new ArrayList<>(arr.size()); + for (JsonElement item : arr) { + list.add(toJavaObject(item)); + } + return list; + } + + private static Object toPrimitive(JsonPrimitive prim) { + if (prim.isBoolean()) { + return prim.getAsBoolean(); + } + if (prim.isString()) { + return prim.getAsString(); + } + return toNumber(prim.getAsString()); + } + + private static Object toNumber(String raw) { + if (raw.indexOf('.') < 0 && raw.indexOf('e') < 0 && raw.indexOf('E') < 0) { + try { + return Long.parseLong(raw); + } catch (NumberFormatException _) { + // Falls through to Double for out-of-Long-range integers. + } + } + return Double.parseDouble(raw); + } + private static TypeAdapter isoStringWriter(Function toIso) { return new TypeAdapter() { @Override diff --git a/src/test/java/com/retailsvc/http/OpenApiServerTest.java b/src/test/java/com/retailsvc/http/OpenApiServerTest.java index a325c74..3db7ec5 100644 --- a/src/test/java/com/retailsvc/http/OpenApiServerTest.java +++ b/src/test/java/com/retailsvc/http/OpenApiServerTest.java @@ -29,27 +29,28 @@ void shouldStartHttpServerWithValidConfiguration() { @Test void shouldThrowExceptionWhenSpecIsNull() { - assertThatThrownBy(() -> OpenApiServer.builder().handlers(emptyMap()).port(0).build()) + OpenApiServer.Builder builder = OpenApiServer.builder().handlers(emptyMap()).port(0); + + assertThatThrownBy(builder::build) .isInstanceOf(NullPointerException.class) .hasMessageContaining("Spec must not be null"); } @Test void shouldThrowExceptionWhenHandlersMapIsNull() { - Spec validSpec = testSpec(); + OpenApiServer.Builder builder = OpenApiServer.builder().spec(testSpec()).port(0); - assertThatThrownBy(() -> OpenApiServer.builder().spec(validSpec).port(0).build()) + assertThatThrownBy(builder::build) .isInstanceOf(NullPointerException.class) .hasMessageContaining("handlers must not be null"); } @Test void testExceptionIsThrownOnInvalidHttpPort() { - Spec validSpec = testSpec(); + OpenApiServer.Builder builder = + OpenApiServer.builder().spec(testSpec()).handlers(emptyMap()).port(-1); - assertThatThrownBy( - () -> OpenApiServer.builder().spec(validSpec).handlers(emptyMap()).port(-1).build()) - .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(builder::build).isInstanceOf(IllegalArgumentException.class); } private Spec testSpec() { diff --git a/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java b/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java index be5b692..73b7f6c 100644 --- a/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java +++ b/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java @@ -12,6 +12,7 @@ import java.net.http.HttpRequest.BodyPublishers; import java.nio.charset.StandardCharsets; import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; import org.junit.jupiter.api.Test; class TypeMapperRegistrationTest extends ServerBaseTest { @@ -52,10 +53,12 @@ void gsonFallbackIsAutoRegisteredWhenNoJsonMapperConfigured() throws Exception { @Test void userSuppliedMapperOverridesDefault() throws Exception { + AtomicBoolean readFromCalled = new AtomicBoolean(); TypeMapper marker = new TypeMapper() { @Override public Object readFrom(byte[] b, String h) { + readFromCalled.set(true); return Map.of("aList", java.util.List.of("x"), "feelingGood", true); } @@ -65,20 +68,31 @@ public byte[] writeTo(Object v) { } }; RequestHandler echo = req -> req.respond(200).empty(); - OpenApiServer s = + server = OpenApiServer.builder() .spec(spec) .bodyMapper("application/json", marker) .handlers(Map.of("get-data", echo, "post-data", echo)) .port(0) .build(); - s.close(); + HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder() + .uri(URI.create("http://localhost:%d/api/v1/data".formatted(server.listenPort()))) + .header("Content-Type", "application/json") + .POST(BodyPublishers.ofString("{\"anything\":\"goes\"}")) + .build(), + ofString()); + + assertThat(readFromCalled).isTrue(); } @Test void bodyMapperRejectsNullArgs() { - var b = OpenApiServer.builder(); - assertThatThrownBy(() -> b.bodyMapper(null, new GsonOnlyMapper())) + OpenApiServer.Builder b = OpenApiServer.builder(); + TypeMapper anyMapper = new GsonOnlyMapper(); + + assertThatThrownBy(() -> b.bodyMapper(null, anyMapper)) .isInstanceOf(NullPointerException.class); assertThatThrownBy(() -> b.bodyMapper("application/json", null)) .isInstanceOf(NullPointerException.class); diff --git a/src/test/java/com/retailsvc/http/internal/FormTypeMapperTest.java b/src/test/java/com/retailsvc/http/internal/FormTypeMapperTest.java index eac7250..2cb9f4f 100644 --- a/src/test/java/com/retailsvc/http/internal/FormTypeMapperTest.java +++ b/src/test/java/com/retailsvc/http/internal/FormTypeMapperTest.java @@ -23,7 +23,9 @@ void readsKeyValuePairs() { @Test void writeToIsUnsupported() { - assertThatThrownBy(() -> mapper.writeTo(Map.of("k", "v"))) + Map value = Map.of("k", "v"); + + assertThatThrownBy(() -> mapper.writeTo(value)) .isInstanceOf(UnsupportedOperationException.class); } } diff --git a/src/test/java/com/retailsvc/http/internal/gson/GsonJsonMapperTest.java b/src/test/java/com/retailsvc/http/internal/gson/GsonJsonMapperTest.java index 7291306..6d39e86 100644 --- a/src/test/java/com/retailsvc/http/internal/gson/GsonJsonMapperTest.java +++ b/src/test/java/com/retailsvc/http/internal/gson/GsonJsonMapperTest.java @@ -41,10 +41,11 @@ void readBasicTypes() { (Map) mapper.readFrom( bytes("{\"s\":\"hi\",\"b\":true,\"n\":null,\"a\":[1,2]}"), "application/json"); - assertThat(parsed.get("s")).isEqualTo("hi"); - assertThat(parsed.get("b")).isEqualTo(Boolean.TRUE); - assertThat(parsed.get("n")).isNull(); - assertThat(parsed.get("a")).isEqualTo(List.of(1L, 2L)); + assertThat(parsed) + .containsEntry("s", "hi") + .containsEntry("b", Boolean.TRUE) + .containsEntry("n", null) + .containsEntry("a", List.of(1L, 2L)); } @Test From b6af2a02d29fc9f42290e767cba9286a742f3cd4 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 14 May 2026 17:04:45 +0200 Subject: [PATCH 28/50] feat: Expose query params on Request (queryParams, queryParam, rawQuery) --- src/main/java/com/retailsvc/http/Request.java | 47 +++++++++++++++++++ .../java/com/retailsvc/http/RequestTest.java | 28 +++++++++++ 2 files changed, 75 insertions(+) diff --git a/src/main/java/com/retailsvc/http/Request.java b/src/main/java/com/retailsvc/http/Request.java index 5d32c9d..97c6fa1 100644 --- a/src/main/java/com/retailsvc/http/Request.java +++ b/src/main/java/com/retailsvc/http/Request.java @@ -2,6 +2,9 @@ import com.retailsvc.http.internal.DefaultResponseBuilder; import com.sun.net.httpserver.HttpExchange; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; import java.util.Map; /** @@ -16,6 +19,7 @@ public final class Request { private final String operationId; private final Map pathParameters; private final Map bodyMappers; + private Map queryParamCache; private boolean responseSent; public Request( @@ -53,6 +57,49 @@ public String header(String name) { return exchange.getRequestHeaders().getFirst(name); } + /** + * Raw (percent-encoded) query string from the request URI, or {@code null} if the URI has no + * query component. + */ + public String rawQuery() { + return exchange.getRequestURI().getRawQuery(); + } + + /** + * Decoded query parameters keyed by name. Empty if the URI has no query. For repeated keys, the + * first occurrence wins. Values are URL-decoded with UTF-8. + */ + public Map queryParams() { + if (queryParamCache == null) { + queryParamCache = parseQuery(rawQuery()); + } + return queryParamCache; + } + + /** First decoded value for {@code name}, or {@code null} if absent. */ + public String queryParam(String name) { + return queryParams().get(name); + } + + private static Map parseQuery(String query) { + if (query == null || query.isBlank()) { + return Map.of(); + } + Map out = new LinkedHashMap<>(); + for (String pair : query.split("&")) { + if (pair.isEmpty()) { + continue; + } + int eq = pair.indexOf('='); + String rawKey = eq < 0 ? pair : pair.substring(0, eq); + String rawValue = eq < 0 ? "" : pair.substring(eq + 1); + out.putIfAbsent( + URLDecoder.decode(rawKey, StandardCharsets.UTF_8), + URLDecoder.decode(rawValue, StandardCharsets.UTF_8)); + } + return out; + } + public ResponseBuilder respond(int status) { if (responseSent) { throw new IllegalStateException("Response already sent"); diff --git a/src/test/java/com/retailsvc/http/RequestTest.java b/src/test/java/com/retailsvc/http/RequestTest.java index a598098..1ab4b04 100644 --- a/src/test/java/com/retailsvc/http/RequestTest.java +++ b/src/test/java/com/retailsvc/http/RequestTest.java @@ -8,6 +8,7 @@ import com.retailsvc.http.internal.DispatchHandler; import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpExchange; +import java.net.URI; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; @@ -48,6 +49,33 @@ void readsBoundContext() throws Exception { assertThat(seenPathParams.get()).containsEntry("id", "42"); } + @Test + void exposesQueryParams() { + HttpExchange exchange = mock(HttpExchange.class); + when(exchange.getRequestURI()) + .thenReturn(URI.create("http://h/x?name=Alice%20Smith&active=true&active=false")); + Request req = new Request(exchange, new byte[0], null, "op", Map.of(), Map.of()); + + assertThat(req.rawQuery()).isEqualTo("name=Alice%20Smith&active=true&active=false"); + assertThat(req.queryParam("name")).isEqualTo("Alice Smith"); + assertThat(req.queryParam("active")).isEqualTo("true"); + assertThat(req.queryParam("missing")).isNull(); + assertThat(req.queryParams()) + .containsEntry("name", "Alice Smith") + .containsEntry("active", "true"); + } + + @Test + void queryParamsEmptyWhenNoQuery() { + HttpExchange exchange = mock(HttpExchange.class); + when(exchange.getRequestURI()).thenReturn(URI.create("http://h/x")); + Request req = new Request(exchange, new byte[0], null, "op", Map.of(), Map.of()); + + assertThat(req.rawQuery()).isNull(); + assertThat(req.queryParams()).isEmpty(); + assertThat(req.queryParam("anything")).isNull(); + } + @Test void respondAfterTerminalThrows() throws Exception { HttpExchange exchange = mock(HttpExchange.class); From f1e6ed1cd9b6071974cd58b9fe19e1d8e4c7142d Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 14 May 2026 17:05:23 +0200 Subject: [PATCH 29/50] docs: Document path/query/header accessors in README example --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index d82db42..c9049dc 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,10 @@ public class PostDataHandler implements RequestHandler { byte[] body = request.bytes(); // Or get the already-parsed object (Map / List) produced by the registered TypeMapper. Object parsed = request.parsed(); + // Path parameters, query parameters, and headers are also available. + String id = request.pathParams().get("id"); + String filter = request.queryParam("filter"); + String corr = request.header("correlation-id"); request.respond(200).json(parsed); } From 48f7f0d150c41280051d67fca90415dd4fc0a400 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 14 May 2026 17:27:07 +0200 Subject: [PATCH 30/50] feat: Add ResponseDecorator and RequestInterceptor for cross-cutting concerns --- README.md | 54 ++++++++ .../com/retailsvc/http/OpenApiServer.java | 40 +++++- src/main/java/com/retailsvc/http/Request.java | 13 +- .../retailsvc/http/RequestInterceptor.java | 24 ++++ .../com/retailsvc/http/ResponseDecorator.java | 14 +++ .../http/internal/DispatchHandler.java | 17 ++- .../internal/RequestPreparationFilter.java | 18 ++- .../http/DecoratorAndInterceptorIT.java | 117 ++++++++++++++++++ .../java/com/retailsvc/http/RequestTest.java | 10 +- .../http/internal/DispatchHandlerTest.java | 8 +- .../RequestPreparationFilterTest.java | 6 +- 11 files changed, 304 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/retailsvc/http/RequestInterceptor.java create mode 100644 src/main/java/com/retailsvc/http/ResponseDecorator.java create mode 100644 src/test/java/com/retailsvc/http/DecoratorAndInterceptorIT.java diff --git a/README.md b/README.md index c9049dc..7c215c8 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,59 @@ Register a custom mapper for any media type via `Builder.bodyMapper(mediaType, m User-supplied mappers take precedence over built-in defaults, so you can override any of the above. +### Response decorators + +`Builder.responseDecorator(...)` registers a `ResponseDecorator` that runs whenever a handler calls `request.respond(status)`. Decorators set headers (or other pre-terminal state) before the handler reaches a terminal call. They run in registration order, and handler-supplied headers override decorator-supplied ones (last write wins). + +Use cases: stamping correlation IDs, tenant IDs, server identifiers, or any cross-cutting header on every response. + +``` java +OpenApiServer.builder() + .spec(spec) + .handlers(handlers) + .responseDecorator( + (request, response) -> + response.header("X-Correlation-Id", CorrelationId.current())) + .responseDecorator( + (request, response) -> response.header("X-Tenant-Id", TenantId.current())) + .build(); +``` + +`ResponseDecorator` is a `@FunctionalInterface`; the lambda receives the `Request` and the `ResponseBuilder` that's about to be returned from `respond(...)`. Don't call a terminal method (`empty()` / `bytes()` / `json()` / ...) from a decorator — terminals belong to the handler. + +### Request interceptors + +`Builder.interceptor(...)` registers a `RequestInterceptor` that wraps every handler invocation. Use it for `ScopedValue` bindings, MDC, authentication, tracing, or any concern that needs to run uniformly around handlers. Interceptors compose in registration order: the first registered runs outermost. + +``` java +OpenApiServer.builder() + .spec(spec) + .handlers(handlers) + .interceptor( + (request, next) -> { + // Resolve once per request. + String tenant = request.header("X-Tenant-Id"); + ScopedValue.where(TENANT, tenant) + .call( + () -> { + next.proceed(); + return null; + }); + }) + .interceptor( + (request, next) -> { + MDC.put("op", request.operationId()); + try { + next.proceed(); + } finally { + MDC.remove("op"); + } + }) + .build(); +``` + +Each interceptor must call `next.proceed()` to continue the chain. Exceptions propagate to the library's standard `ExceptionFilter` and `ExceptionHandler` pipeline. + ### Request body content types The server reads `requestBody.content` from the spec and selects a mapper by the request's media type (the bare `type/subtype` from `Content-Type`, e.g. `application/json`; lookup is case-insensitive): @@ -224,6 +277,7 @@ try (var server = OpenApiServer.builder() - `RequestHandler` functional interface — a single `handle(Request)` method replaces raw `HttpExchange` manipulation - Fluent `ResponseBuilder` via `request.respond(status)` with terminals: `empty()`, `bytes()`, `text()`, `json()`, `body()`, `stream()` - Built-in `GsonJsonMapper` auto-registered when Gson is on the classpath (no explicit wiring needed) +- `ResponseDecorator` for cross-cutting response headers and `RequestInterceptor` for around-style ScopedValue / MDC / auth concerns - Built on Java's native `HttpServer` with Thread-Per-Request behaviour using Virtual Threads diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index 2e4b6d4..11b8eab 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -17,7 +17,9 @@ import com.sun.net.httpserver.HttpServer; import java.io.IOException; import java.net.InetSocketAddress; +import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; @@ -44,6 +46,8 @@ public class OpenApiServer implements AutoCloseable { Spec spec, Map bodyMappers, Map handlers, + List decorators, + List interceptors, ExceptionHandler exceptionHandler, int port, Map extras, @@ -67,8 +71,9 @@ public class OpenApiServer implements AutoCloseable { HttpContext ctx = httpServer.createContext(Optional.ofNullable(spec.basePath()).orElse("/")); ctx.getFilters().add(new ExceptionFilter(exceptionHandler)); - ctx.getFilters().add(new RequestPreparationFilter(spec, router, validator, bodyMappers)); - ctx.setHandler(new DispatchHandler(handlers)); + ctx.getFilters() + .add(new RequestPreparationFilter(spec, router, validator, bodyMappers, decorators)); + ctx.setHandler(new DispatchHandler(handlers, interceptors)); for (Map.Entry e : extras.entrySet()) { HttpContext extraCtx = httpServer.createContext(e.getKey()); @@ -118,6 +123,8 @@ public static final class Builder { private Spec spec; private final LinkedHashMap bodyMappers = new LinkedHashMap<>(); private Map handlers; + private final List decorators = new ArrayList<>(); + private final List interceptors = new ArrayList<>(); private ExceptionHandler exceptionHandler; private int port = DEFAULT_PORT; private int shutdownTimeoutSeconds = 0; @@ -142,6 +149,25 @@ public Builder handlers(Map handlers) { return this; } + /** + * Registers a {@link ResponseDecorator} that mutates the {@link ResponseBuilder} returned by + * {@link Request#respond(int)} before the handler receives it. Decorators run in registration + * order; handler-supplied headers override decorator-supplied ones. + */ + public Builder responseDecorator(ResponseDecorator decorator) { + decorators.add(requireNonNull(decorator, "decorator must not be null")); + return this; + } + + /** + * Registers a {@link RequestInterceptor} that wraps the handler invocation. Interceptors run in + * registration order; the first registered is the outermost. + */ + public Builder interceptor(RequestInterceptor interceptor) { + interceptors.add(requireNonNull(interceptor, "interceptor must not be null")); + return this; + } + public Builder exceptionHandler(ExceptionHandler exceptionHandler) { this.exceptionHandler = exceptionHandler; return this; @@ -188,7 +214,15 @@ public OpenApiServer build() throws IOException { } Map resolved = resolveBodyMappers(bodyMappers); return new OpenApiServer( - spec, resolved, handlers, exceptionHandler, port, extras, shutdownTimeoutSeconds); + spec, + resolved, + handlers, + decorators, + interceptors, + exceptionHandler, + port, + extras, + shutdownTimeoutSeconds); } private static Map resolveBodyMappers( diff --git a/src/main/java/com/retailsvc/http/Request.java b/src/main/java/com/retailsvc/http/Request.java index 97c6fa1..68861f7 100644 --- a/src/main/java/com/retailsvc/http/Request.java +++ b/src/main/java/com/retailsvc/http/Request.java @@ -5,6 +5,7 @@ import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** @@ -19,6 +20,7 @@ public final class Request { private final String operationId; private final Map pathParameters; private final Map bodyMappers; + private final List decorators; private Map queryParamCache; private boolean responseSent; @@ -28,13 +30,15 @@ public Request( Object parsed, String operationId, Map pathParameters, - Map bodyMappers) { + Map bodyMappers, + List decorators) { this.exchange = exchange; this.body = body; this.parsed = parsed; this.operationId = operationId; this.pathParameters = pathParameters; this.bodyMappers = bodyMappers; + this.decorators = List.copyOf(decorators); } public byte[] bytes() { @@ -104,6 +108,11 @@ public ResponseBuilder respond(int status) { if (responseSent) { throw new IllegalStateException("Response already sent"); } - return new DefaultResponseBuilder(exchange, status, bodyMappers, () -> responseSent = true); + ResponseBuilder builder = + new DefaultResponseBuilder(exchange, status, bodyMappers, () -> responseSent = true); + for (ResponseDecorator decorator : decorators) { + decorator.decorate(this, builder); + } + return builder; } } diff --git a/src/main/java/com/retailsvc/http/RequestInterceptor.java b/src/main/java/com/retailsvc/http/RequestInterceptor.java new file mode 100644 index 0000000..555eeea --- /dev/null +++ b/src/main/java/com/retailsvc/http/RequestInterceptor.java @@ -0,0 +1,24 @@ +package com.retailsvc.http; + +import java.io.IOException; + +/** + * Wraps the {@link RequestHandler} invocation. Use for {@link ScopedValue} bindings, MDC, tracing, + * authentication, or any other concern that should run uniformly around every handler. + * + *

Interceptors compose in registration order: the first registered runs outermost. Each + * interceptor must call {@link Continuation#proceed()} to invoke the next interceptor (or the + * handler, when last). Exceptions propagate to the library's standard {@code ExceptionFilter} and + * {@code ExceptionHandler} pipeline. + */ +@FunctionalInterface +public interface RequestInterceptor { + + void around(Request request, Continuation next) throws IOException; + + /** Continues the chain — calls the next interceptor, or the handler if this is the last one. */ + @FunctionalInterface + interface Continuation { + void proceed() throws IOException; + } +} diff --git a/src/main/java/com/retailsvc/http/ResponseDecorator.java b/src/main/java/com/retailsvc/http/ResponseDecorator.java new file mode 100644 index 0000000..7e35e03a --- /dev/null +++ b/src/main/java/com/retailsvc/http/ResponseDecorator.java @@ -0,0 +1,14 @@ +package com.retailsvc.http; + +/** + * Mutates the {@link ResponseBuilder} returned by {@link Request#respond(int)} before the handler + * receives it. Decorators run in registration order. They may set headers (including {@code + * Content-Type}) but must not call a terminal method — terminals belong to the handler. + * + *

Decorators fire before the handler runs, so any headers the handler sets via the returned + * builder override decorator-supplied values. + */ +@FunctionalInterface +public interface ResponseDecorator { + void decorate(Request request, ResponseBuilder builder); +} diff --git a/src/main/java/com/retailsvc/http/internal/DispatchHandler.java b/src/main/java/com/retailsvc/http/internal/DispatchHandler.java index 1ee5a78..c471038 100644 --- a/src/main/java/com/retailsvc/http/internal/DispatchHandler.java +++ b/src/main/java/com/retailsvc/http/internal/DispatchHandler.java @@ -3,9 +3,11 @@ import com.retailsvc.http.MissingOperationHandlerException; import com.retailsvc.http.Request; import com.retailsvc.http.RequestHandler; +import com.retailsvc.http.RequestInterceptor; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import java.io.IOException; +import java.util.List; import java.util.Map; public final class DispatchHandler implements HttpHandler { @@ -13,9 +15,12 @@ public final class DispatchHandler implements HttpHandler { public static final ScopedValue CURRENT = ScopedValue.newInstance(); private final Map handlers; + private final List interceptors; - public DispatchHandler(Map handlers) { + public DispatchHandler( + Map handlers, List interceptors) { this.handlers = Map.copyOf(handlers); + this.interceptors = List.copyOf(interceptors); } @Override @@ -25,6 +30,14 @@ public void handle(HttpExchange exchange) throws IOException { if (h == null) { throw new MissingOperationHandlerException(request.operationId()); } - h.handle(request); + invoke(0, request, h); + } + + private void invoke(int idx, Request request, RequestHandler handler) throws IOException { + if (idx == interceptors.size()) { + handler.handle(request); + return; + } + interceptors.get(idx).around(request, () -> invoke(idx + 1, request, handler)); } } diff --git a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java index af608c9..6938cd3 100644 --- a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java +++ b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java @@ -3,6 +3,7 @@ import com.retailsvc.http.MethodNotAllowedException; import com.retailsvc.http.NotFoundException; import com.retailsvc.http.Request; +import com.retailsvc.http.ResponseDecorator; import com.retailsvc.http.TypeMapper; import com.retailsvc.http.ValidationException; import com.retailsvc.http.spec.HttpMethod; @@ -17,6 +18,7 @@ import com.sun.net.httpserver.HttpExchange; import java.io.IOException; import java.util.HashMap; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; @@ -29,13 +31,19 @@ public final class RequestPreparationFilter extends Filter { private final Router router; private final Validator validator; private final Map bodyMappers; + private final List decorators; public RequestPreparationFilter( - Spec spec, Router router, Validator validator, Map bodyMappers) { + Spec spec, + Router router, + Validator validator, + Map bodyMappers, + List decorators) { this.spec = spec; this.router = router; this.validator = validator; this.bodyMappers = Map.copyOf(bodyMappers); + this.decorators = List.copyOf(decorators); } @Override @@ -66,7 +74,13 @@ public void doFilter(HttpExchange exchange, Chain chain) throws IOException { Request request = new Request( - exchange, body, parsedBody, op.operationId(), match.pathParameters(), bodyMappers); + exchange, + body, + parsedBody, + op.operationId(), + match.pathParameters(), + bodyMappers, + decorators); try { ScopedValue.where(DispatchHandler.CURRENT, request) diff --git a/src/test/java/com/retailsvc/http/DecoratorAndInterceptorIT.java b/src/test/java/com/retailsvc/http/DecoratorAndInterceptorIT.java new file mode 100644 index 0000000..b83bb8a --- /dev/null +++ b/src/test/java/com/retailsvc/http/DecoratorAndInterceptorIT.java @@ -0,0 +1,117 @@ +package com.retailsvc.http; + +import static java.net.HttpURLConnection.HTTP_OK; +import static java.net.http.HttpResponse.BodyHandlers.ofString; +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class DecoratorAndInterceptorIT extends ServerBaseTest { + + static final ScopedValue TENANT = ScopedValue.newInstance(); + + @Test + void responseDecoratorAddsHeadersOnEveryResponse() throws Exception { + RequestHandler ok = req -> req.respond(HTTP_OK).text("ok"); + server = + OpenApiServer.builder() + .spec(spec) + .handlers(Map.of("get-data", ok, "post-data", ok)) + .responseDecorator( + (request, builder) -> builder.header("X-Correlation-Id", "decorator-cid")) + .responseDecorator((request, builder) -> builder.header("X-Op", request.operationId())) + .port(0) + .build(); + + var resp = call("/api/v1/data"); + + assertThat(resp.statusCode()).isEqualTo(HTTP_OK); + assertThat(resp.headers().firstValue("X-Correlation-Id")).contains("decorator-cid"); + assertThat(resp.headers().firstValue("X-Op")).contains("get-data"); + } + + @Test + void handlerHeaderOverridesDecoratorHeader() throws Exception { + RequestHandler ok = req -> req.respond(HTTP_OK).header("X-Op", "handler-wins").text("ok"); + server = + OpenApiServer.builder() + .spec(spec) + .handlers(Map.of("get-data", ok, "post-data", ok)) + .responseDecorator((request, builder) -> builder.header("X-Op", "decorator")) + .port(0) + .build(); + + var resp = call("/api/v1/data"); + + assertThat(resp.headers().firstValue("X-Op")).contains("handler-wins"); + } + + @Test + void interceptorBindsScopedValueVisibleToHandler() throws Exception { + RequestHandler echoTenant = req -> req.respond(HTTP_OK).text(TENANT.get()); + server = + OpenApiServer.builder() + .spec(spec) + .handlers(Map.of("get-data", echoTenant, "post-data", echoTenant)) + .interceptor( + (request, next) -> + ScopedValue.where(TENANT, "acme") + .call( + () -> { + next.proceed(); + return null; + })) + .port(0) + .build(); + + assertThat(call("/api/v1/data").body()).isEqualTo("acme"); + } + + @Test + void interceptorsRunInRegistrationOrder() throws Exception { + List trace = new java.util.concurrent.CopyOnWriteArrayList<>(); + RequestHandler ok = + req -> { + trace.add("handler"); + req.respond(HTTP_OK).empty(); + }; + server = + OpenApiServer.builder() + .spec(spec) + .handlers(Map.of("get-data", ok, "post-data", ok)) + .interceptor( + (request, next) -> { + trace.add("outer-before"); + next.proceed(); + trace.add("outer-after"); + }) + .interceptor( + (request, next) -> { + trace.add("inner-before"); + next.proceed(); + trace.add("inner-after"); + }) + .port(0) + .build(); + + call("/api/v1/data"); + + assertThat(trace) + .containsExactly("outer-before", "inner-before", "handler", "inner-after", "outer-after"); + } + + private java.net.http.HttpResponse call(String path) throws Exception { + return HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder() + .uri(URI.create("http://localhost:%d%s".formatted(server.listenPort(), path))) + .GET() + .build(), + ofString()); + } +} diff --git a/src/test/java/com/retailsvc/http/RequestTest.java b/src/test/java/com/retailsvc/http/RequestTest.java index 1ab4b04..fcacfb6 100644 --- a/src/test/java/com/retailsvc/http/RequestTest.java +++ b/src/test/java/com/retailsvc/http/RequestTest.java @@ -9,6 +9,7 @@ import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpExchange; import java.net.URI; +import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; @@ -25,7 +26,8 @@ void readsBoundContext() throws Exception { Map.of("k", "v"), "get-x", Map.of("id", "42"), - Map.of()); + Map.of(), + List.of()); AtomicReference seenBytes = new AtomicReference<>(); AtomicReference seenParsed = new AtomicReference<>(); @@ -54,7 +56,7 @@ void exposesQueryParams() { HttpExchange exchange = mock(HttpExchange.class); when(exchange.getRequestURI()) .thenReturn(URI.create("http://h/x?name=Alice%20Smith&active=true&active=false")); - Request req = new Request(exchange, new byte[0], null, "op", Map.of(), Map.of()); + Request req = new Request(exchange, new byte[0], null, "op", Map.of(), Map.of(), List.of()); assertThat(req.rawQuery()).isEqualTo("name=Alice%20Smith&active=true&active=false"); assertThat(req.queryParam("name")).isEqualTo("Alice Smith"); @@ -69,7 +71,7 @@ void exposesQueryParams() { void queryParamsEmptyWhenNoQuery() { HttpExchange exchange = mock(HttpExchange.class); when(exchange.getRequestURI()).thenReturn(URI.create("http://h/x")); - Request req = new Request(exchange, new byte[0], null, "op", Map.of(), Map.of()); + Request req = new Request(exchange, new byte[0], null, "op", Map.of(), Map.of(), List.of()); assertThat(req.rawQuery()).isNull(); assertThat(req.queryParams()).isEmpty(); @@ -80,7 +82,7 @@ void queryParamsEmptyWhenNoQuery() { void respondAfterTerminalThrows() throws Exception { HttpExchange exchange = mock(HttpExchange.class); when(exchange.getResponseHeaders()).thenReturn(new Headers()); - Request req = new Request(exchange, new byte[0], null, "op", Map.of(), Map.of()); + Request req = new Request(exchange, new byte[0], null, "op", Map.of(), Map.of(), List.of()); req.respond(204).empty(); diff --git a/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java b/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java index f3842a8..f1b867d 100644 --- a/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java +++ b/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java @@ -8,6 +8,7 @@ import com.retailsvc.http.Request; import com.retailsvc.http.RequestHandler; import com.sun.net.httpserver.HttpExchange; +import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; import org.junit.jupiter.api.Test; @@ -17,7 +18,8 @@ class DispatchHandlerTest { private static void withRequest(String operationId, ScopedValue.CallableOp body) throws Exception { HttpExchange exchange = mock(HttpExchange.class); - Request req = new Request(exchange, new byte[0], null, operationId, Map.of(), Map.of()); + Request req = + new Request(exchange, new byte[0], null, operationId, Map.of(), Map.of(), List.of()); ScopedValue.where(DispatchHandler.CURRENT, req).call(body); } @@ -30,7 +32,7 @@ void invokesRegisteredHandler() throws Exception { withRequest( "get-x", () -> { - new DispatchHandler(Map.of("get-x", handler)).handle(ex); + new DispatchHandler(Map.of("get-x", handler), List.of()).handle(ex); return null; }); @@ -39,7 +41,7 @@ void invokesRegisteredHandler() throws Exception { @Test void throwsWhenHandlerMissing() { - DispatchHandler d = new DispatchHandler(Map.of()); + DispatchHandler d = new DispatchHandler(Map.of(), List.of()); HttpExchange ex = mock(HttpExchange.class); assertThatThrownBy( diff --git a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java index e07d43f..49dba77 100644 --- a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java +++ b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java @@ -76,7 +76,11 @@ public byte[] writeTo(Object value) { }; Map mappers = Map.of("application/json", textMapper); return new RequestPreparationFilter( - spec, new Router(spec.operations()), new DefaultValidator(spec::resolveSchema), mappers); + spec, + new Router(spec.operations()), + new DefaultValidator(spec::resolveSchema), + mappers, + List.of()); } @Test From 07619868dc16399fa1e313e273dec96e4eb27c95 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 14 May 2026 17:29:14 +0200 Subject: [PATCH 31/50] docs: Document combining interceptors and decorators in README --- README.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/README.md b/README.md index 7c215c8..8c0e858 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,61 @@ OpenApiServer.builder() Each interceptor must call `next.proceed()` to continue the chain. Exceptions propagate to the library's standard `ExceptionFilter` and `ExceptionHandler` pipeline. +### Combining interceptors and decorators + +The two collaborate naturally: the interceptor binds per-request context once, and the decorator reads that context when stamping response headers. Handlers stay free of cross-cutting code. + +``` java +// Per-request context populated by the interceptor, read by the decorator and handlers. +ScopedValue CORRELATION_ID = ScopedValue.newInstance(); +ScopedValue TENANT_ID = ScopedValue.newInstance(); + +OpenApiServer.builder() + .spec(spec) + .handlers(handlers) + // 1. Resolve once per request and bind to ScopedValues. + .interceptor( + (request, next) -> { + String correlationId = + Optional.ofNullable(request.header("X-Correlation-Id")) + .orElseGet(() -> UUID.randomUUID().toString()); + String tenantId = resolveTenant(request); + ScopedValue.where(CORRELATION_ID, correlationId) + .where(TENANT_ID, tenantId) + .call( + () -> { + next.proceed(); + return null; + }); + }) + // 2. Stamp those values on every response. + .responseDecorator( + (request, response) -> { + response.header("X-Correlation-Id", CORRELATION_ID.get()); + response.header("X-Tenant-Id", TENANT_ID.get()); + }) + .build(); +``` + +Inside any handler, `CORRELATION_ID.get()` / `TENANT_ID.get()` return the resolved values — no parameter threading, no static accessors. Because the decorator runs *inside* the interceptor's `ScopedValue` binding (decorators fire on `request.respond(...)`, which the handler calls while the interceptor's `proceed()` is still on the stack), the `get()` calls always see the bound value. + +A handler in this setup is just business logic: + +``` java +public class GetPromotionHandler implements RequestHandler { + @Override + public void handle(Request request) throws IOException { + String id = request.pathParams().get("id"); + String tenant = TENANT_ID.get(); + promotionService + .find(tenant, id) + .ifPresentOrElse( + promotion -> request.respond(HTTP_OK).json(promotion), + () -> request.respond(HTTP_NOT_FOUND).empty()); + } +} +``` + ### Request body content types The server reads `requestBody.content` from the spec and selects a mapper by the request's media type (the bare `type/subtype` from `Content-Type`, e.g. `application/json`; lookup is case-insensitive): From 2e02e532718536bd3320924ad36ab431e677a868 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 14 May 2026 17:57:02 +0200 Subject: [PATCH 32/50] feat!: Handlers return Response value object; remove ResponseBuilder Handlers now return an immutable Response record instead of mutating an HttpExchange via the ResponseBuilder fluent API. Response is a pure value: status + body + contentType + headers, with factories for common shapes (empty/status/ok/of/text/bytes/stream). ResponseDecorator becomes a (Request, Response) -> Response transform applied after the handler returns; interceptors and continuations return Response too so cross-cutting work composes cleanly. Removed: ResponseBuilder, DefaultResponseBuilder, Request.respond(int), the per-Request responseSent flag, and the IllegalStateException state machine - single-shot is enforced by the return type. Renderer (internal ResponseRenderer) handles byte[] / BodyWriter / mapper.writeTo dispatch, including null-body -> responseLength=-1. --- README.md | 135 +++++++++-------- .../com/retailsvc/http/OpenApiServer.java | 13 +- src/main/java/com/retailsvc/http/Request.java | 28 +--- .../com/retailsvc/http/RequestHandler.java | 10 +- .../retailsvc/http/RequestInterceptor.java | 11 +- .../java/com/retailsvc/http/Response.java | 114 ++++++++++++++ .../com/retailsvc/http/ResponseBuilder.java | 35 ----- .../com/retailsvc/http/ResponseDecorator.java | 13 +- .../retailsvc/http/internal/BodyWriter.java | 35 +++++ .../http/internal/DefaultResponseBuilder.java | 142 ------------------ .../http/internal/DispatchHandler.java | 28 +++- .../internal/RequestPreparationFilter.java | 19 +-- .../http/internal/ResponseRenderer.java | 77 ++++++++++ .../http/DecoratorAndInterceptorIT.java | 34 ++--- .../com/retailsvc/http/OpenApiServerIT.java | 18 ++- .../http/RequestResponseGatewayTest.java | 26 ++-- .../java/com/retailsvc/http/RequestTest.java | 29 +--- .../http/TypeMapperRegistrationTest.java | 12 +- .../http/internal/DispatchHandlerTest.java | 37 +++-- .../RequestPreparationFilterTest.java | 6 +- .../com/retailsvc/http/start/EchoHandler.java | 15 +- .../retailsvc/http/start/FormEchoHandler.java | 11 +- .../retailsvc/http/start/GetDataHandler.java | 18 +-- .../retailsvc/http/start/ParamHandler.java | 9 +- .../retailsvc/http/start/PostDataHandler.java | 14 +- .../http/start/PostListObjectsHandler.java | 20 +-- .../retailsvc/http/start/TextEchoHandler.java | 11 +- 27 files changed, 463 insertions(+), 457 deletions(-) create mode 100644 src/main/java/com/retailsvc/http/Response.java delete mode 100644 src/main/java/com/retailsvc/http/ResponseBuilder.java create mode 100644 src/main/java/com/retailsvc/http/internal/BodyWriter.java delete mode 100644 src/main/java/com/retailsvc/http/internal/DefaultResponseBuilder.java create mode 100644 src/main/java/com/retailsvc/http/internal/ResponseRenderer.java diff --git a/README.md b/README.md index 8c0e858..c1405e9 100644 --- a/README.md +++ b/README.md @@ -26,16 +26,15 @@ It is designed to be simple to use while providing the essential features needed ### Basic Usage 1. Create an OpenAPI specification file named `openapi.json` in your project resources. -2. Define your handlers using the `RequestHandler` functional interface: +2. Define your handlers using the `RequestHandler` functional interface. Handlers are pure functions: they consume a `Request` and return a `Response`. The framework renders the response (status code, headers, body) for you. ``` java // Inline lambda — returns JSON using the built-in Gson mapper. -RequestHandler getDataHandler = req -> - req.respond(200).json(Map.of("id", "some-id")); +RequestHandler getDataHandler = req -> Response.ok(Map.of("id", "some-id")); // Class form — reads raw bytes or the pre-parsed body object. public class PostDataHandler implements RequestHandler { @Override - public void handle(Request request) throws IOException { + public Response handle(Request request) { // Access the raw request body bytes. byte[] body = request.bytes(); // Or get the already-parsed object (Map / List) produced by the registered TypeMapper. @@ -45,11 +44,39 @@ public class PostDataHandler implements RequestHandler { String filter = request.queryParam("filter"); String corr = request.header("correlation-id"); - request.respond(200).json(parsed); + return Response.ok(parsed); } } ``` +### Building responses + +`Response` is an immutable record built via static factories. Pick the one that fits: + +``` java +Response.empty(); // 204 No Content, no body +Response.status(404); // 404, no body +Response.status(200); // 200 OK, no body +Response.ok(Map.of("id", "42")); // 200 OK, JSON body via TypeMapper +Response.of(201, newResource); // any status, JSON body +Response.text(200, "hello"); // text/plain; UTF-8 +Response.bytes(200, pdf, "application/pdf"); // pre-serialised bytes +Response.stream(200, "application/octet-stream", // chunked streaming + out -> out.write(largeBlob)); +Response.stream(200, length, "application/pdf", // sized streaming + out -> pipeFromBackend(out)); +``` + +Add or modify pieces non-destructively: + +``` java +return Response.ok(payload) + .withHeader("X-Tenant-Id", tenant) + .withContentType("application/vnd.example+json"); +``` + +A `null` body always produces a status-only response (`Content-Length: 0`, no body bytes), regardless of status code. Streaming bodies bypass `TypeMapper` entirely; one-shot object bodies (`ok`, `of`) are serialised by the `TypeMapper` registered for the response's content type (default `application/json`). + 3. Initialize the server: ``` java public class YourServerLauncher { @@ -122,60 +149,46 @@ User-supplied mappers take precedence over built-in defaults, so you can overrid ### Response decorators -`Builder.responseDecorator(...)` registers a `ResponseDecorator` that runs whenever a handler calls `request.respond(status)`. Decorators set headers (or other pre-terminal state) before the handler reaches a terminal call. They run in registration order, and handler-supplied headers override decorator-supplied ones (last write wins). - -Use cases: stamping correlation IDs, tenant IDs, server identifiers, or any cross-cutting header on every response. +`Builder.responseDecorator(...)` registers a `ResponseDecorator` — a `(Request, Response) -> Response` transform applied to every handler's return value before rendering. Decorators compose in registration order: the result of one is fed to the next. Decorator-supplied headers override handler-supplied ones; if you want the opposite, set the header inside the handler with `Response.withHeader(...)`. ``` java OpenApiServer.builder() .spec(spec) .handlers(handlers) - .responseDecorator( - (request, response) -> - response.header("X-Correlation-Id", CorrelationId.current())) - .responseDecorator( - (request, response) -> response.header("X-Tenant-Id", TenantId.current())) + .responseDecorator((req, resp) -> resp.withHeader("X-Correlation-Id", CorrelationId.current())) + .responseDecorator((req, resp) -> resp.withHeader("X-Tenant-Id", TenantId.current())) .build(); ``` -`ResponseDecorator` is a `@FunctionalInterface`; the lambda receives the `Request` and the `ResponseBuilder` that's about to be returned from `respond(...)`. Don't call a terminal method (`empty()` / `bytes()` / `json()` / ...) from a decorator — terminals belong to the handler. - ### Request interceptors -`Builder.interceptor(...)` registers a `RequestInterceptor` that wraps every handler invocation. Use it for `ScopedValue` bindings, MDC, authentication, tracing, or any concern that needs to run uniformly around handlers. Interceptors compose in registration order: the first registered runs outermost. +`Builder.interceptor(...)` registers a `RequestInterceptor` that wraps every handler invocation. Use it for `ScopedValue` bindings, MDC, authentication, tracing, or any concern that needs to run uniformly around handlers. Interceptors compose in registration order: the first registered runs outermost. Each interceptor must call `next.proceed()` and return the result (or a transformed `Response`). ``` java OpenApiServer.builder() .spec(spec) .handlers(handlers) - .interceptor( - (request, next) -> { - // Resolve once per request. - String tenant = request.header("X-Tenant-Id"); - ScopedValue.where(TENANT, tenant) - .call( - () -> { - next.proceed(); - return null; - }); - }) - .interceptor( - (request, next) -> { - MDC.put("op", request.operationId()); - try { - next.proceed(); - } finally { - MDC.remove("op"); - } - }) + .interceptor((request, next) -> { + // Resolve once per request; bind to a ScopedValue for the rest of the chain. + String tenant = request.header("X-Tenant-Id"); + return ScopedValue.where(TENANT, tenant).call(next::proceed); + }) + .interceptor((request, next) -> { + MDC.put("op", request.operationId()); + try { + return next.proceed(); + } finally { + MDC.remove("op"); + } + }) .build(); ``` -Each interceptor must call `next.proceed()` to continue the chain. Exceptions propagate to the library's standard `ExceptionFilter` and `ExceptionHandler` pipeline. +Exceptions propagate to the library's standard `ExceptionFilter` and `ExceptionHandler` pipeline. ### Combining interceptors and decorators -The two collaborate naturally: the interceptor binds per-request context once, and the decorator reads that context when stamping response headers. Handlers stay free of cross-cutting code. +The two collaborate naturally: the interceptor binds per-request context once, and the decorator reads that context when stamping response headers. Handlers stay pure business logic. ``` java // Per-request context populated by the interceptor, read by the decorator and handlers. @@ -186,44 +199,36 @@ OpenApiServer.builder() .spec(spec) .handlers(handlers) // 1. Resolve once per request and bind to ScopedValues. - .interceptor( - (request, next) -> { - String correlationId = - Optional.ofNullable(request.header("X-Correlation-Id")) - .orElseGet(() -> UUID.randomUUID().toString()); - String tenantId = resolveTenant(request); - ScopedValue.where(CORRELATION_ID, correlationId) - .where(TENANT_ID, tenantId) - .call( - () -> { - next.proceed(); - return null; - }); - }) + .interceptor((request, next) -> { + String correlationId = + Optional.ofNullable(request.header("X-Correlation-Id")) + .orElseGet(() -> UUID.randomUUID().toString()); + String tenantId = resolveTenant(request); + return ScopedValue.where(CORRELATION_ID, correlationId) + .where(TENANT_ID, tenantId) + .call(next::proceed); + }) // 2. Stamp those values on every response. - .responseDecorator( - (request, response) -> { - response.header("X-Correlation-Id", CORRELATION_ID.get()); - response.header("X-Tenant-Id", TENANT_ID.get()); - }) + .responseDecorator((req, resp) -> resp + .withHeader("X-Correlation-Id", CORRELATION_ID.get()) + .withHeader("X-Tenant-Id", TENANT_ID.get())) .build(); ``` -Inside any handler, `CORRELATION_ID.get()` / `TENANT_ID.get()` return the resolved values — no parameter threading, no static accessors. Because the decorator runs *inside* the interceptor's `ScopedValue` binding (decorators fire on `request.respond(...)`, which the handler calls while the interceptor's `proceed()` is still on the stack), the `get()` calls always see the bound value. +Decorators run inside the interceptor's `ScopedValue` binding (the decorator transforms the `Response` returned by `next.proceed()`, which is still on the call stack), so `CORRELATION_ID.get()` / `TENANT_ID.get()` see the bound values. A handler in this setup is just business logic: ``` java public class GetPromotionHandler implements RequestHandler { @Override - public void handle(Request request) throws IOException { + public Response handle(Request request) { String id = request.pathParams().get("id"); String tenant = TENANT_ID.get(); - promotionService + return promotionService .find(tenant, id) - .ifPresentOrElse( - promotion -> request.respond(HTTP_OK).json(promotion), - () -> request.respond(HTTP_NOT_FOUND).empty()); + .map(p -> Response.of(HTTP_OK, p)) + .orElse(Response.status(HTTP_NOT_FOUND)); } } ``` @@ -330,7 +335,7 @@ try (var server = OpenApiServer.builder() - OpenAPI specification support - Automatic request body parsing and response writing per media type via `TypeMapper` - `RequestHandler` functional interface — a single `handle(Request)` method replaces raw `HttpExchange` manipulation -- Fluent `ResponseBuilder` via `request.respond(status)` with terminals: `empty()`, `bytes()`, `text()`, `json()`, `body()`, `stream()` +- Handlers are pure functions: `Response handle(Request)`. Factories cover `empty()` / `status(int)` / `ok(Object)` / `of(int, Object)` / `text(int, String)` / `bytes(int, byte[], String)` / `stream(...)` - Built-in `GsonJsonMapper` auto-registered when Gson is on the classpath (no explicit wiring needed) - `ResponseDecorator` for cross-cutting response headers and `RequestInterceptor` for around-style ScopedValue / MDC / auth concerns - Built on Java's native `HttpServer` with Thread-Per-Request behaviour using Virtual Threads @@ -358,6 +363,6 @@ A few things to know: - **Single-process model.** No horizontal scaling primitives are bundled; run multiple instances behind a load balancer for production scale. - **JDK HttpServer is the throughput ceiling.** It's documented as a low-throughput / dev-test server. If you need to go materially above the rates above, deploy the same filter/validator/router stack on Jetty, Helidon Níma, or Netty — the spec and validation code is server-agnostic. - **Per-request state uses `ScopedValue`** (Java 25, JEP 506). This matters if a handler offloads work to an executor that's not a `StructuredTaskScope`-managed child thread: the `ScopedValue` is not visible there, so the handler must capture the values it needs (e.g. `byte[] body = request.bytes();`) before submitting. -- **Empty responses must use `request.respond(status).empty()`**, which sends `responseLength = -1` (`Content-Length: 0`, no body). Passing `0` produces a chunked response with zero chunks, which is technically non-conformant. +- **Empty responses use `Response.empty()` (204) or `Response.status(code)` for other no-body statuses.** The renderer sends `responseLength = -1` (`Content-Length: 0`, no body) for any `Response` with `body() == null`, regardless of status code. Passing `0` to the JDK directly produces a chunked response with zero chunks, which is technically non-conformant — `Response` factories handle this for you. ## Known limitations or missing features diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index 11b8eab..246d800 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -8,6 +8,7 @@ import com.retailsvc.http.internal.ExceptionFilter; import com.retailsvc.http.internal.FormTypeMapper; import com.retailsvc.http.internal.RequestPreparationFilter; +import com.retailsvc.http.internal.ResponseRenderer; import com.retailsvc.http.internal.Router; import com.retailsvc.http.internal.TextTypeMapper; import com.retailsvc.http.spec.Spec; @@ -71,9 +72,9 @@ public class OpenApiServer implements AutoCloseable { HttpContext ctx = httpServer.createContext(Optional.ofNullable(spec.basePath()).orElse("/")); ctx.getFilters().add(new ExceptionFilter(exceptionHandler)); - ctx.getFilters() - .add(new RequestPreparationFilter(spec, router, validator, bodyMappers, decorators)); - ctx.setHandler(new DispatchHandler(handlers, interceptors)); + ctx.getFilters().add(new RequestPreparationFilter(spec, router, validator, bodyMappers)); + ctx.setHandler( + new DispatchHandler(handlers, interceptors, decorators, new ResponseRenderer(bodyMappers))); for (Map.Entry e : extras.entrySet()) { HttpContext extraCtx = httpServer.createContext(e.getKey()); @@ -150,9 +151,9 @@ public Builder handlers(Map handlers) { } /** - * Registers a {@link ResponseDecorator} that mutates the {@link ResponseBuilder} returned by - * {@link Request#respond(int)} before the handler receives it. Decorators run in registration - * order; handler-supplied headers override decorator-supplied ones. + * Registers a {@link ResponseDecorator} that transforms the {@link Response} returned by the + * handler before it is rendered. Decorators compose in registration order; decorator-supplied + * headers override handler-supplied ones on conflict. */ public Builder responseDecorator(ResponseDecorator decorator) { decorators.add(requireNonNull(decorator, "decorator must not be null")); diff --git a/src/main/java/com/retailsvc/http/Request.java b/src/main/java/com/retailsvc/http/Request.java index 68861f7..7a06316 100644 --- a/src/main/java/com/retailsvc/http/Request.java +++ b/src/main/java/com/retailsvc/http/Request.java @@ -1,16 +1,15 @@ package com.retailsvc.http; -import com.retailsvc.http.internal.DefaultResponseBuilder; import com.sun.net.httpserver.HttpExchange; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; /** - * The per-request handle passed to {@link RequestHandler}. Carries the parsed body, path - * parameters, operation ID, and a fluent {@link ResponseBuilder} for writing the response. + * Read-only per-request handle passed to {@link RequestHandler}. Carries the parsed body, path + * parameters, query parameters, headers, and operation ID. Handlers consume a {@code Request} and + * return a {@link Response}. */ public final class Request { @@ -19,26 +18,19 @@ public final class Request { private final Object parsed; private final String operationId; private final Map pathParameters; - private final Map bodyMappers; - private final List decorators; private Map queryParamCache; - private boolean responseSent; public Request( HttpExchange exchange, byte[] body, Object parsed, String operationId, - Map pathParameters, - Map bodyMappers, - List decorators) { + Map pathParameters) { this.exchange = exchange; this.body = body; this.parsed = parsed; this.operationId = operationId; this.pathParameters = pathParameters; - this.bodyMappers = bodyMappers; - this.decorators = List.copyOf(decorators); } public byte[] bytes() { @@ -103,16 +95,4 @@ private static Map parseQuery(String query) { } return out; } - - public ResponseBuilder respond(int status) { - if (responseSent) { - throw new IllegalStateException("Response already sent"); - } - ResponseBuilder builder = - new DefaultResponseBuilder(exchange, status, bodyMappers, () -> responseSent = true); - for (ResponseDecorator decorator : decorators) { - decorator.decorate(this, builder); - } - return builder; - } } diff --git a/src/main/java/com/retailsvc/http/RequestHandler.java b/src/main/java/com/retailsvc/http/RequestHandler.java index db15f50..43493d6 100644 --- a/src/main/java/com/retailsvc/http/RequestHandler.java +++ b/src/main/java/com/retailsvc/http/RequestHandler.java @@ -1,12 +1,16 @@ package com.retailsvc.http; -import java.io.IOException; - /** * Handles a single request identified by OpenAPI {@code operationId}. Registered on {@link * OpenApiServer.Builder#handlers(java.util.Map)} by operation ID. + * + *

Handlers are pure functions of the {@link Request}: they read inputs and return a {@link + * Response} describing what should be sent. The framework renders the response after applying any + * registered {@link ResponseDecorator}s. Handlers may throw any {@link RuntimeException}; the + * configured {@link ExceptionHandler} renders it. Handlers that need to surface an {@code + * IOException} should wrap it as {@link java.io.UncheckedIOException}. */ @FunctionalInterface public interface RequestHandler { - void handle(Request request) throws IOException; + Response handle(Request request); } diff --git a/src/main/java/com/retailsvc/http/RequestInterceptor.java b/src/main/java/com/retailsvc/http/RequestInterceptor.java index 555eeea..c855fbb 100644 --- a/src/main/java/com/retailsvc/http/RequestInterceptor.java +++ b/src/main/java/com/retailsvc/http/RequestInterceptor.java @@ -1,24 +1,21 @@ package com.retailsvc.http; -import java.io.IOException; - /** * Wraps the {@link RequestHandler} invocation. Use for {@link ScopedValue} bindings, MDC, tracing, * authentication, or any other concern that should run uniformly around every handler. * *

Interceptors compose in registration order: the first registered runs outermost. Each - * interceptor must call {@link Continuation#proceed()} to invoke the next interceptor (or the - * handler, when last). Exceptions propagate to the library's standard {@code ExceptionFilter} and - * {@code ExceptionHandler} pipeline. + * interceptor must call {@link Continuation#proceed()} and return its result (or a transformed + * value). Exceptions propagate to the configured {@link ExceptionHandler}. */ @FunctionalInterface public interface RequestInterceptor { - void around(Request request, Continuation next) throws IOException; + Response around(Request request, Continuation next); /** Continues the chain — calls the next interceptor, or the handler if this is the last one. */ @FunctionalInterface interface Continuation { - void proceed() throws IOException; + Response proceed(); } } diff --git a/src/main/java/com/retailsvc/http/Response.java b/src/main/java/com/retailsvc/http/Response.java new file mode 100644 index 0000000..561ea2f --- /dev/null +++ b/src/main/java/com/retailsvc/http/Response.java @@ -0,0 +1,114 @@ +package com.retailsvc.http; + +import com.retailsvc.http.internal.BodyWriter; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * The value returned by every {@link RequestHandler}. Carries status, optional body, optional + * content type, and headers. The framework renders it to the underlying {@code HttpExchange} after + * any registered {@link ResponseDecorator}s have transformed it. + * + *

Body handling: + * + *

    + *
  • {@code null} body → no response body (status only). + *
  • {@code byte[]} body → written verbatim with the supplied content type. + *
  • Streaming body (via {@link #stream(int, String, StreamingBody)} / sized variant) → written + * incrementally. + *
  • Any other object → serialised by the {@link TypeMapper} registered for the response's + * content type (default {@code application/json}). + *
+ */ +public record Response(int status, Object body, String contentType, Map headers) { + + public Response { + headers = headers == null ? Map.of() : Map.copyOf(headers); + } + + // -- one-shot, no-body -- + + /** {@code 204 No Content} with no body. */ + public static Response empty() { + return new Response(204, null, null, Map.of()); + } + + /** Given status, no body. Use for {@code 200 OK} no body, {@code 404}, {@code 405}, etc. */ + public static Response status(int status) { + return new Response(status, null, null, Map.of()); + } + + // -- one-shot, JSON body -- + + /** {@code 200 OK} with {@code body} serialised as JSON. */ + public static Response ok(Object body) { + return new Response(200, body, null, Map.of()); + } + + /** {@code status} with {@code body} serialised by the content-type's {@link TypeMapper}. */ + public static Response of(int status, Object body) { + return new Response(status, body, null, Map.of()); + } + + // -- one-shot, text / raw bytes -- + + /** {@code status} with {@code body} written as UTF-8 with {@code Content-Type: text/plain}. */ + public static Response text(int status, String body) { + return new Response( + status, body.getBytes(StandardCharsets.UTF_8), "text/plain; charset=UTF-8", Map.of()); + } + + /** + * {@code status} with pre-serialised {@code bytes} written verbatim under {@code contentType}. + */ + public static Response bytes(int status, byte[] bytes, String contentType) { + return new Response(status, bytes, contentType, Map.of()); + } + + // -- streaming -- + + /** Streaming response with unknown length (chunked transfer encoding). */ + public static Response stream(int status, String contentType, StreamingBody writer) { + return new Response(status, new BodyWriter.Chunked(writer::writeTo), contentType, Map.of()); + } + + /** Streaming response with a known content length. */ + public static Response stream(int status, long length, String contentType, StreamingBody writer) { + if (length < 0) { + throw new IllegalArgumentException("length must be non-negative"); + } + return new Response( + status, new BodyWriter.Sized(length, writer::writeTo), contentType, Map.of()); + } + + // -- non-destructive mutators -- + + public Response withStatus(int newStatus) { + return new Response(newStatus, body, contentType, headers); + } + + public Response withContentType(String newContentType) { + return new Response(status, body, newContentType, headers); + } + + public Response withHeader(String name, String value) { + LinkedHashMap merged = new LinkedHashMap<>(headers); + merged.put(name, value); + return new Response(status, body, contentType, merged); + } + + public Response withHeaders(Map additional) { + LinkedHashMap merged = new LinkedHashMap<>(headers); + merged.putAll(additional); + return new Response(status, body, contentType, merged); + } + + /** Writer signature for {@link #stream(int, String, StreamingBody)}. */ + @FunctionalInterface + public interface StreamingBody { + void writeTo(OutputStream out) throws IOException; + } +} diff --git a/src/main/java/com/retailsvc/http/ResponseBuilder.java b/src/main/java/com/retailsvc/http/ResponseBuilder.java deleted file mode 100644 index 6f703e1..0000000 --- a/src/main/java/com/retailsvc/http/ResponseBuilder.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.retailsvc.http; - -import java.io.IOException; -import java.io.OutputStream; - -/** - * Fluent response builder returned by {@link Request#respond(int)}. Each {@code Request} permits - * exactly one terminal call ({@link #empty()}, {@link #bytes(byte[])}, {@link #text(String)}, - * {@link #json(Object)}, {@link #body(String, Object)}, {@link #stream()}, or {@link - * #stream(long)}); calling any of them after the first throws {@link IllegalStateException}. {@link - * #header(String, String)} / {@link #contentType(String)} must be called before the terminal. - * - *

Note: a {@code problem(...)} terminal is deferred — no public {@code ProblemDetail} type - * exists yet; only the internal {@code ProblemDetailRenderer} is available. - */ -public interface ResponseBuilder { - - ResponseBuilder header(String name, String value); - - ResponseBuilder contentType(String contentType); - - void empty() throws IOException; - - void bytes(byte[] body) throws IOException; - - void text(String body) throws IOException; - - void json(Object body) throws IOException; - - void body(String mediaType, Object body) throws IOException; - - OutputStream stream() throws IOException; - - OutputStream stream(long length) throws IOException; -} diff --git a/src/main/java/com/retailsvc/http/ResponseDecorator.java b/src/main/java/com/retailsvc/http/ResponseDecorator.java index 7e35e03a..5603a98 100644 --- a/src/main/java/com/retailsvc/http/ResponseDecorator.java +++ b/src/main/java/com/retailsvc/http/ResponseDecorator.java @@ -1,14 +1,15 @@ package com.retailsvc.http; /** - * Mutates the {@link ResponseBuilder} returned by {@link Request#respond(int)} before the handler - * receives it. Decorators run in registration order. They may set headers (including {@code - * Content-Type}) but must not call a terminal method — terminals belong to the handler. + * Transforms the {@link Response} returned by a handler before the framework renders it. Decorators + * run in registration order; the result of each is fed to the next. Use for cross-cutting headers + * (correlation id, tenant id, server identifier) or any other uniform response shaping. * - *

Decorators fire before the handler runs, so any headers the handler sets via the returned - * builder override decorator-supplied values. + *

Because decorators run after the handler, decorator-supplied headers override + * handler-supplied ones on conflict. If you need the opposite semantics, use {@link + * Response#withHeaders(java.util.Map)} inside the handler instead. */ @FunctionalInterface public interface ResponseDecorator { - void decorate(Request request, ResponseBuilder builder); + Response decorate(Request request, Response response); } diff --git a/src/main/java/com/retailsvc/http/internal/BodyWriter.java b/src/main/java/com/retailsvc/http/internal/BodyWriter.java new file mode 100644 index 0000000..ca6c7f7 --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/BodyWriter.java @@ -0,0 +1,35 @@ +package com.retailsvc.http.internal; + +import java.io.IOException; +import java.io.OutputStream; + +/** + * Internal carrier for streaming response bodies. Constructed by {@code Response} streaming + * factories and recognised by the renderer; never exposed as a public type. + */ +public sealed interface BodyWriter permits BodyWriter.Sized, BodyWriter.Chunked { + + void writeTo(OutputStream out) throws IOException; + + /** Known {@code Content-Length}. */ + record Sized(long length, IOConsumer writer) implements BodyWriter { + @Override + public void writeTo(OutputStream out) throws IOException { + writer.accept(out); + } + } + + /** Unknown length — chunked transfer encoding. */ + record Chunked(IOConsumer writer) implements BodyWriter { + @Override + public void writeTo(OutputStream out) throws IOException { + writer.accept(out); + } + } + + /** {@code Consumer} that is allowed to throw {@link IOException}. */ + @FunctionalInterface + interface IOConsumer { + void accept(OutputStream out) throws IOException; + } +} diff --git a/src/main/java/com/retailsvc/http/internal/DefaultResponseBuilder.java b/src/main/java/com/retailsvc/http/internal/DefaultResponseBuilder.java deleted file mode 100644 index e2d53db..0000000 --- a/src/main/java/com/retailsvc/http/internal/DefaultResponseBuilder.java +++ /dev/null @@ -1,142 +0,0 @@ -package com.retailsvc.http.internal; - -import com.retailsvc.http.ResponseBuilder; -import com.retailsvc.http.TypeMapper; -import com.sun.net.httpserver.HttpExchange; -import java.io.FilterOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.nio.charset.StandardCharsets; -import java.util.LinkedHashMap; -import java.util.Map; - -public final class DefaultResponseBuilder implements ResponseBuilder { - - private static final String CONTENT_TYPE = "Content-Type"; - - private final HttpExchange exchange; - private final int status; - private final Map mappers; - private final Runnable onTerminated; - private final Map pendingHeaders = new LinkedHashMap<>(); - private boolean terminated; - - public DefaultResponseBuilder( - HttpExchange exchange, int status, Map mappers, Runnable onTerminated) { - this.exchange = exchange; - this.status = status; - this.mappers = mappers; - this.onTerminated = onTerminated; - } - - @Override - public ResponseBuilder header(String name, String value) { - checkNotTerminated(); - pendingHeaders.put(name, value); - return this; - } - - @Override - public ResponseBuilder contentType(String contentType) { - return header(CONTENT_TYPE, contentType); - } - - @Override - public void empty() throws IOException { - terminate(); - try (exchange) { - applyHeaders(); - exchange.sendResponseHeaders(status, -1); - } - } - - @Override - public void bytes(byte[] body) throws IOException { - terminate(); - try (exchange) { - applyHeaders(); - exchange.sendResponseHeaders(status, body.length == 0 ? -1 : body.length); - if (body.length > 0) { - try (OutputStream out = exchange.getResponseBody()) { - out.write(body); - } - } - } - } - - @Override - public void text(String body) throws IOException { - pendingHeaders.putIfAbsent(CONTENT_TYPE, "text/plain; charset=UTF-8"); - bytes(body.getBytes(StandardCharsets.UTF_8)); - } - - @Override - public void json(Object body) throws IOException { - this.body("application/json", body); - } - - @Override - public void body(String mediaType, Object value) throws IOException { - TypeMapper mapper = mappers.get(mediaType.toLowerCase(java.util.Locale.ROOT)); - if (mapper == null) { - throw new IllegalStateException("No TypeMapper registered for " + mediaType); - } - pendingHeaders.putIfAbsent(CONTENT_TYPE, mediaType); - bytes(mapper.writeTo(value)); - } - - @Override - public OutputStream stream() throws IOException { - terminate(); - applyHeaders(); - exchange.sendResponseHeaders(status, 0); - return closingExchange(exchange.getResponseBody()); - } - - @Override - public OutputStream stream(long length) throws IOException { - if (length < 0) { - throw new IllegalArgumentException("length must be non-negative"); - } - terminate(); - applyHeaders(); - exchange.sendResponseHeaders(status, length); - return closingExchange(exchange.getResponseBody()); - } - - /** - * Wraps the response body so the underlying {@link HttpExchange} is closed once the caller closes - * the stream. - */ - private OutputStream closingExchange(OutputStream body) { - return new FilterOutputStream(body) { - @Override - public void write(byte[] b, int off, int len) throws IOException { - out.write(b, off, len); - } - - @Override - public void close() throws IOException { - try (exchange) { - super.close(); - } - } - }; - } - - private void terminate() { - checkNotTerminated(); - terminated = true; - onTerminated.run(); - } - - private void checkNotTerminated() { - if (terminated) { - throw new IllegalStateException("Response already sent"); - } - } - - private void applyHeaders() { - pendingHeaders.forEach(exchange.getResponseHeaders()::add); - } -} diff --git a/src/main/java/com/retailsvc/http/internal/DispatchHandler.java b/src/main/java/com/retailsvc/http/internal/DispatchHandler.java index c471038..d1234e9 100644 --- a/src/main/java/com/retailsvc/http/internal/DispatchHandler.java +++ b/src/main/java/com/retailsvc/http/internal/DispatchHandler.java @@ -4,6 +4,8 @@ import com.retailsvc.http.Request; import com.retailsvc.http.RequestHandler; import com.retailsvc.http.RequestInterceptor; +import com.retailsvc.http.Response; +import com.retailsvc.http.ResponseDecorator; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import java.io.IOException; @@ -16,28 +18,38 @@ public final class DispatchHandler implements HttpHandler { private final Map handlers; private final List interceptors; + private final List decorators; + private final ResponseRenderer renderer; public DispatchHandler( - Map handlers, List interceptors) { + Map handlers, + List interceptors, + List decorators, + ResponseRenderer renderer) { this.handlers = Map.copyOf(handlers); this.interceptors = List.copyOf(interceptors); + this.decorators = List.copyOf(decorators); + this.renderer = renderer; } @Override public void handle(HttpExchange exchange) throws IOException { Request request = CURRENT.get(); - RequestHandler h = handlers.get(request.operationId()); - if (h == null) { + RequestHandler handler = handlers.get(request.operationId()); + if (handler == null) { throw new MissingOperationHandlerException(request.operationId()); } - invoke(0, request, h); + Response response = invoke(0, request, handler); + for (ResponseDecorator decorator : decorators) { + response = decorator.decorate(request, response); + } + renderer.render(exchange, response); } - private void invoke(int idx, Request request, RequestHandler handler) throws IOException { + private Response invoke(int idx, Request request, RequestHandler handler) { if (idx == interceptors.size()) { - handler.handle(request); - return; + return handler.handle(request); } - interceptors.get(idx).around(request, () -> invoke(idx + 1, request, handler)); + return interceptors.get(idx).around(request, () -> invoke(idx + 1, request, handler)); } } diff --git a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java index 6938cd3..18657a9 100644 --- a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java +++ b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java @@ -3,7 +3,6 @@ import com.retailsvc.http.MethodNotAllowedException; import com.retailsvc.http.NotFoundException; import com.retailsvc.http.Request; -import com.retailsvc.http.ResponseDecorator; import com.retailsvc.http.TypeMapper; import com.retailsvc.http.ValidationException; import com.retailsvc.http.spec.HttpMethod; @@ -18,7 +17,6 @@ import com.sun.net.httpserver.HttpExchange; import java.io.IOException; import java.util.HashMap; -import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; @@ -31,19 +29,13 @@ public final class RequestPreparationFilter extends Filter { private final Router router; private final Validator validator; private final Map bodyMappers; - private final List decorators; public RequestPreparationFilter( - Spec spec, - Router router, - Validator validator, - Map bodyMappers, - List decorators) { + Spec spec, Router router, Validator validator, Map bodyMappers) { this.spec = spec; this.router = router; this.validator = validator; this.bodyMappers = Map.copyOf(bodyMappers); - this.decorators = List.copyOf(decorators); } @Override @@ -73,14 +65,7 @@ public void doFilter(HttpExchange exchange, Chain chain) throws IOException { Object parsedBody = validateAndParseBody(exchange, op, body); Request request = - new Request( - exchange, - body, - parsedBody, - op.operationId(), - match.pathParameters(), - bodyMappers, - decorators); + new Request(exchange, body, parsedBody, op.operationId(), match.pathParameters()); try { ScopedValue.where(DispatchHandler.CURRENT, request) diff --git a/src/main/java/com/retailsvc/http/internal/ResponseRenderer.java b/src/main/java/com/retailsvc/http/internal/ResponseRenderer.java new file mode 100644 index 0000000..094f0a5 --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/ResponseRenderer.java @@ -0,0 +1,77 @@ +package com.retailsvc.http.internal; + +import com.retailsvc.http.Response; +import com.retailsvc.http.TypeMapper; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import java.io.IOException; +import java.io.OutputStream; +import java.util.Locale; +import java.util.Map; + +/** Writes a {@link Response} to an {@link HttpExchange}. */ +public final class ResponseRenderer { + + private static final String CONTENT_TYPE = "Content-Type"; + private static final String DEFAULT_JSON = "application/json"; + + private final Map mappers; + + public ResponseRenderer(Map mappers) { + this.mappers = Map.copyOf(mappers); + } + + public void render(HttpExchange exchange, Response response) throws IOException { + try (exchange) { + Headers headers = exchange.getResponseHeaders(); + response.headers().forEach(headers::add); + + Object body = response.body(); + int status = response.status(); + + if (body == null) { + exchange.sendResponseHeaders(status, -1); + return; + } + + if (body instanceof BodyWriter writer) { + long length = writer instanceof BodyWriter.Sized sized ? sized.length() : 0; + if (response.contentType() != null && !headers.containsKey(CONTENT_TYPE)) { + headers.add(CONTENT_TYPE, response.contentType()); + } + exchange.sendResponseHeaders(status, length); + try (OutputStream out = exchange.getResponseBody()) { + writer.writeTo(out); + } + return; + } + + byte[] bytes; + String contentType = response.contentType(); + if (body instanceof byte[] raw) { + bytes = raw; + if (contentType == null) { + contentType = "application/octet-stream"; + } + } else { + if (contentType == null) { + contentType = DEFAULT_JSON; + } + TypeMapper mapper = mappers.get(contentType.toLowerCase(Locale.ROOT)); + if (mapper == null) { + throw new IllegalStateException("No TypeMapper registered for " + contentType); + } + bytes = mapper.writeTo(body); + } + if (!headers.containsKey(CONTENT_TYPE)) { + headers.add(CONTENT_TYPE, contentType); + } + exchange.sendResponseHeaders(status, bytes.length == 0 ? -1 : bytes.length); + if (bytes.length > 0) { + try (OutputStream out = exchange.getResponseBody()) { + out.write(bytes); + } + } + } + } +} diff --git a/src/test/java/com/retailsvc/http/DecoratorAndInterceptorIT.java b/src/test/java/com/retailsvc/http/DecoratorAndInterceptorIT.java index b83bb8a..b054129 100644 --- a/src/test/java/com/retailsvc/http/DecoratorAndInterceptorIT.java +++ b/src/test/java/com/retailsvc/http/DecoratorAndInterceptorIT.java @@ -17,14 +17,13 @@ class DecoratorAndInterceptorIT extends ServerBaseTest { @Test void responseDecoratorAddsHeadersOnEveryResponse() throws Exception { - RequestHandler ok = req -> req.respond(HTTP_OK).text("ok"); + RequestHandler ok = req -> Response.text(HTTP_OK, "ok"); server = OpenApiServer.builder() .spec(spec) .handlers(Map.of("get-data", ok, "post-data", ok)) - .responseDecorator( - (request, builder) -> builder.header("X-Correlation-Id", "decorator-cid")) - .responseDecorator((request, builder) -> builder.header("X-Op", request.operationId())) + .responseDecorator((req, resp) -> resp.withHeader("X-Correlation-Id", "decorator-cid")) + .responseDecorator((req, resp) -> resp.withHeader("X-Op", req.operationId())) .port(0) .build(); @@ -36,36 +35,29 @@ void responseDecoratorAddsHeadersOnEveryResponse() throws Exception { } @Test - void handlerHeaderOverridesDecoratorHeader() throws Exception { - RequestHandler ok = req -> req.respond(HTTP_OK).header("X-Op", "handler-wins").text("ok"); + void decoratorHeaderOverridesHandlerHeader() throws Exception { + RequestHandler ok = req -> Response.text(HTTP_OK, "ok").withHeader("X-Op", "handler-set"); server = OpenApiServer.builder() .spec(spec) .handlers(Map.of("get-data", ok, "post-data", ok)) - .responseDecorator((request, builder) -> builder.header("X-Op", "decorator")) + .responseDecorator((req, resp) -> resp.withHeader("X-Op", "decorator-wins")) .port(0) .build(); var resp = call("/api/v1/data"); - assertThat(resp.headers().firstValue("X-Op")).contains("handler-wins"); + assertThat(resp.headers().firstValue("X-Op")).contains("decorator-wins"); } @Test void interceptorBindsScopedValueVisibleToHandler() throws Exception { - RequestHandler echoTenant = req -> req.respond(HTTP_OK).text(TENANT.get()); + RequestHandler echoTenant = req -> Response.text(HTTP_OK, TENANT.get()); server = OpenApiServer.builder() .spec(spec) .handlers(Map.of("get-data", echoTenant, "post-data", echoTenant)) - .interceptor( - (request, next) -> - ScopedValue.where(TENANT, "acme") - .call( - () -> { - next.proceed(); - return null; - })) + .interceptor((request, next) -> ScopedValue.where(TENANT, "acme").call(next::proceed)) .port(0) .build(); @@ -78,7 +70,7 @@ void interceptorsRunInRegistrationOrder() throws Exception { RequestHandler ok = req -> { trace.add("handler"); - req.respond(HTTP_OK).empty(); + return Response.status(HTTP_OK); }; server = OpenApiServer.builder() @@ -87,14 +79,16 @@ void interceptorsRunInRegistrationOrder() throws Exception { .interceptor( (request, next) -> { trace.add("outer-before"); - next.proceed(); + Response r = next.proceed(); trace.add("outer-after"); + return r; }) .interceptor( (request, next) -> { trace.add("inner-before"); - next.proceed(); + Response r = next.proceed(); trace.add("inner-after"); + return r; }) .port(0) .build(); diff --git a/src/test/java/com/retailsvc/http/OpenApiServerIT.java b/src/test/java/com/retailsvc/http/OpenApiServerIT.java index e929822..4190a60 100644 --- a/src/test/java/com/retailsvc/http/OpenApiServerIT.java +++ b/src/test/java/com/retailsvc/http/OpenApiServerIT.java @@ -605,7 +605,8 @@ class FormatEmail { @Test void formatEmailShouldReturnBadRequestOnInvalidEmail() { - try (var server = newServer(Map.of("format-email", req -> req.respond(200).empty())); + try (var server = + newServer(Map.of("format-email", req -> com.retailsvc.http.Response.status(200))); var client = httpClient()) { var request = newRequest(server, path + "?addr=not-an-email", "GET", noBody()); @@ -629,7 +630,8 @@ void formatEmailShouldReturnBadRequestOnInvalidEmail() { @Test void formatEmailShouldReturnOkOnValidEmail() { - try (var server = newServer(Map.of("format-email", req -> req.respond(200).empty())); + try (var server = + newServer(Map.of("format-email", req -> com.retailsvc.http.Response.status(200))); var client = httpClient()) { var request = newRequest(server, path + "?addr=user%40example.com", "GET", noBody()); @@ -655,7 +657,8 @@ class FormatByte { @Test void formatByteShouldReturnBadRequestOnInvalidBase64() { - try (var server = newServer(Map.of("format-byte", req -> req.respond(200).empty())); + try (var server = + newServer(Map.of("format-byte", req -> com.retailsvc.http.Response.status(200))); var client = httpClient()) { var request = newRequest(server, path + "?data=not%20base64!!", "GET", noBody()); @@ -679,7 +682,8 @@ void formatByteShouldReturnBadRequestOnInvalidBase64() { @Test void formatByteShouldReturnOkOnValidBase64() { - try (var server = newServer(Map.of("format-byte", req -> req.respond(200).empty())); + try (var server = + newServer(Map.of("format-byte", req -> com.retailsvc.http.Response.status(200))); var client = httpClient()) { var request = newRequest(server, path + "?data=aGVsbG8%3D", "GET", noBody()); @@ -705,7 +709,8 @@ class FormatInt32 { @Test void formatInt32ShouldReturnBadRequestOnOverflow() { - try (var server = newServer(Map.of("format-int32", req -> req.respond(200).empty())); + try (var server = + newServer(Map.of("format-int32", req -> com.retailsvc.http.Response.status(200))); var client = httpClient()) { var request = newRequest(server, path + "?n=2147483648", "GET", noBody()); @@ -729,7 +734,8 @@ void formatInt32ShouldReturnBadRequestOnOverflow() { @Test void formatInt32ShouldReturnOkOnValidValue() { - try (var server = newServer(Map.of("format-int32", req -> req.respond(200).empty())); + try (var server = + newServer(Map.of("format-int32", req -> com.retailsvc.http.Response.status(200))); var client = httpClient()) { var request = newRequest(server, path + "?n=42", "GET", noBody()); diff --git a/src/test/java/com/retailsvc/http/RequestResponseGatewayTest.java b/src/test/java/com/retailsvc/http/RequestResponseGatewayTest.java index 35146ab..ab32347 100644 --- a/src/test/java/com/retailsvc/http/RequestResponseGatewayTest.java +++ b/src/test/java/com/retailsvc/http/RequestResponseGatewayTest.java @@ -1,5 +1,7 @@ package com.retailsvc.http; +import static java.net.HttpURLConnection.HTTP_NO_CONTENT; +import static java.net.HttpURLConnection.HTTP_OK; import static java.net.http.HttpClient.Version.HTTP_1_1; import static java.net.http.HttpResponse.BodyHandlers.ofString; import static java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor; @@ -16,7 +18,7 @@ class RequestResponseGatewayTest extends ServerBaseTest { @Test void respondJsonWritesBodyAndContentType() throws Exception { - RequestHandler echo = req -> req.respond(200).json(Map.of("op", req.operationId())); + RequestHandler echo = req -> Response.ok(Map.of("op", req.operationId())); server = OpenApiServer.builder() .spec(spec) @@ -36,14 +38,14 @@ void respondJsonWritesBodyAndContentType() throws Exception { .POST(BodyPublishers.ofString("{\"aList\":[\"x\"],\"feelingGood\":true}")) .build(), ofString()); - assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.statusCode()).isEqualTo(HTTP_OK); assertThat(resp.headers().firstValue("Content-Type")).contains("application/json"); assertThat(resp.body()).contains("\"op\":\"post-data\""); } @Test void respondEmptyUses204Style() throws Exception { - RequestHandler ok = req -> req.respond(204).empty(); + RequestHandler ok = req -> Response.status(HTTP_NO_CONTENT); server = OpenApiServer.builder() .spec(spec) @@ -60,19 +62,21 @@ void respondEmptyUses204Style() throws Exception { .GET() .build(), ofString()); - assertThat(resp.statusCode()).isEqualTo(204); + assertThat(resp.statusCode()).isEqualTo(HTTP_NO_CONTENT); assertThat(resp.body()).isEmpty(); } @Test void respondStreamUsesChunkedEncoding() throws Exception { RequestHandler streamer = - req -> { - try (var out = req.respond(200).contentType("text/plain").stream()) { - out.write("hello ".getBytes()); - out.write("world".getBytes()); - } - }; + req -> + Response.stream( + HTTP_OK, + "text/plain", + out -> { + out.write("hello ".getBytes()); + out.write("world".getBytes()); + }); server = OpenApiServer.builder() .spec(spec) @@ -89,7 +93,7 @@ void respondStreamUsesChunkedEncoding() throws Exception { .GET() .build(), ofString()); - assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.statusCode()).isEqualTo(HTTP_OK); assertThat(resp.body()).isEqualTo("hello world"); } } diff --git a/src/test/java/com/retailsvc/http/RequestTest.java b/src/test/java/com/retailsvc/http/RequestTest.java index fcacfb6..d0da319 100644 --- a/src/test/java/com/retailsvc/http/RequestTest.java +++ b/src/test/java/com/retailsvc/http/RequestTest.java @@ -1,15 +1,12 @@ package com.retailsvc.http; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import com.retailsvc.http.internal.DispatchHandler; -import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpExchange; import java.net.URI; -import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; @@ -20,14 +17,7 @@ class RequestTest { void readsBoundContext() throws Exception { HttpExchange exchange = mock(HttpExchange.class); Request req = - new Request( - exchange, - new byte[] {1, 2, 3}, - Map.of("k", "v"), - "get-x", - Map.of("id", "42"), - Map.of(), - List.of()); + new Request(exchange, new byte[] {1, 2, 3}, Map.of("k", "v"), "get-x", Map.of("id", "42")); AtomicReference seenBytes = new AtomicReference<>(); AtomicReference seenParsed = new AtomicReference<>(); @@ -56,7 +46,7 @@ void exposesQueryParams() { HttpExchange exchange = mock(HttpExchange.class); when(exchange.getRequestURI()) .thenReturn(URI.create("http://h/x?name=Alice%20Smith&active=true&active=false")); - Request req = new Request(exchange, new byte[0], null, "op", Map.of(), Map.of(), List.of()); + Request req = new Request(exchange, new byte[0], null, "op", Map.of()); assertThat(req.rawQuery()).isEqualTo("name=Alice%20Smith&active=true&active=false"); assertThat(req.queryParam("name")).isEqualTo("Alice Smith"); @@ -71,23 +61,10 @@ void exposesQueryParams() { void queryParamsEmptyWhenNoQuery() { HttpExchange exchange = mock(HttpExchange.class); when(exchange.getRequestURI()).thenReturn(URI.create("http://h/x")); - Request req = new Request(exchange, new byte[0], null, "op", Map.of(), Map.of(), List.of()); + Request req = new Request(exchange, new byte[0], null, "op", Map.of()); assertThat(req.rawQuery()).isNull(); assertThat(req.queryParams()).isEmpty(); assertThat(req.queryParam("anything")).isNull(); } - - @Test - void respondAfterTerminalThrows() throws Exception { - HttpExchange exchange = mock(HttpExchange.class); - when(exchange.getResponseHeaders()).thenReturn(new Headers()); - Request req = new Request(exchange, new byte[0], null, "op", Map.of(), Map.of(), List.of()); - - req.respond(204).empty(); - - assertThatThrownBy(() -> req.respond(200)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("already sent"); - } } diff --git a/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java b/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java index 73b7f6c..f5e56a3 100644 --- a/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java +++ b/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java @@ -23,11 +23,11 @@ class TypeMapperRegistrationTest extends ServerBaseTest { @Test void gsonFallbackIsAutoRegisteredWhenNoJsonMapperConfigured() throws Exception { RequestHandler echo = - req -> { - Object parsed = req.parsed(); - byte[] out = gson.toJson(parsed).getBytes(StandardCharsets.UTF_8); - req.respond(200).contentType("application/json").bytes(out); - }; + req -> + Response.bytes( + 200, + gson.toJson(req.parsed()).getBytes(StandardCharsets.UTF_8), + "application/json"); server = OpenApiServer.builder() .spec(spec) @@ -67,7 +67,7 @@ public byte[] writeTo(Object v) { return "ignored".getBytes(StandardCharsets.UTF_8); } }; - RequestHandler echo = req -> req.respond(200).empty(); + RequestHandler echo = req -> Response.status(200); server = OpenApiServer.builder() .spec(spec) diff --git a/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java b/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java index f1b867d..5b89897 100644 --- a/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java +++ b/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java @@ -1,12 +1,16 @@ package com.retailsvc.http.internal; +import static java.net.HttpURLConnection.HTTP_OK; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import com.retailsvc.http.MissingOperationHandlerException; import com.retailsvc.http.Request; import com.retailsvc.http.RequestHandler; +import com.retailsvc.http.Response; +import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpExchange; import java.util.List; import java.util.Map; @@ -15,24 +19,38 @@ class DispatchHandlerTest { - private static void withRequest(String operationId, ScopedValue.CallableOp body) - throws Exception { + private static HttpExchange stubExchange() { HttpExchange exchange = mock(HttpExchange.class); - Request req = - new Request(exchange, new byte[0], null, operationId, Map.of(), Map.of(), List.of()); + when(exchange.getResponseHeaders()).thenReturn(new Headers()); + return exchange; + } + + private static DispatchHandler dispatcher(Map handlers) { + return new DispatchHandler(handlers, List.of(), List.of(), new ResponseRenderer(Map.of())); + } + + private static void withRequest( + HttpExchange exchange, String operationId, ScopedValue.CallableOp body) + throws Exception { + Request req = new Request(exchange, new byte[0], null, operationId, Map.of()); ScopedValue.where(DispatchHandler.CURRENT, req).call(body); } @Test void invokesRegisteredHandler() throws Exception { AtomicBoolean called = new AtomicBoolean(false); - RequestHandler handler = req -> called.set(true); - HttpExchange ex = mock(HttpExchange.class); + RequestHandler handler = + req -> { + called.set(true); + return Response.status(HTTP_OK); + }; + HttpExchange ex = stubExchange(); withRequest( + ex, "get-x", () -> { - new DispatchHandler(Map.of("get-x", handler), List.of()).handle(ex); + dispatcher(Map.of("get-x", handler)).handle(ex); return null; }); @@ -41,12 +59,13 @@ void invokesRegisteredHandler() throws Exception { @Test void throwsWhenHandlerMissing() { - DispatchHandler d = new DispatchHandler(Map.of(), List.of()); - HttpExchange ex = mock(HttpExchange.class); + DispatchHandler d = dispatcher(Map.of()); + HttpExchange ex = stubExchange(); assertThatThrownBy( () -> withRequest( + ex, "ghost", () -> { d.handle(ex); diff --git a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java index 49dba77..e07d43f 100644 --- a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java +++ b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java @@ -76,11 +76,7 @@ public byte[] writeTo(Object value) { }; Map mappers = Map.of("application/json", textMapper); return new RequestPreparationFilter( - spec, - new Router(spec.operations()), - new DefaultValidator(spec::resolveSchema), - mappers, - List.of()); + spec, new Router(spec.operations()), new DefaultValidator(spec::resolveSchema), mappers); } @Test diff --git a/src/test/java/com/retailsvc/http/start/EchoHandler.java b/src/test/java/com/retailsvc/http/start/EchoHandler.java index 8413323..fe78205 100644 --- a/src/test/java/com/retailsvc/http/start/EchoHandler.java +++ b/src/test/java/com/retailsvc/http/start/EchoHandler.java @@ -1,30 +1,27 @@ package com.retailsvc.http.start; +import static java.net.HttpURLConnection.HTTP_OK; + import com.retailsvc.http.Request; import com.retailsvc.http.RequestHandler; -import java.io.IOException; +import com.retailsvc.http.Response; import java.lang.invoke.MethodHandles; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -/** Echoes back the request body as a response body */ +/** Echoes back the request body as a response body. */ public class EchoHandler implements RequestHandler { private static final Logger LOG = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); @Override - public void handle(Request request) throws IOException { + public Response handle(Request request) { byte[] bytes = request.bytes(); - if (bytes.length == 0) { LOG.debug("No bytes available to read from the request body"); } else { LOG.debug("Read {} bytes from the request body", bytes.length); } - - String requestBody = new String(bytes); - LOG.debug("Request body: {}", requestBody); - - request.respond(200).contentType("application/json").bytes(requestBody.getBytes()); + return Response.bytes(HTTP_OK, bytes, "application/json"); } } diff --git a/src/test/java/com/retailsvc/http/start/FormEchoHandler.java b/src/test/java/com/retailsvc/http/start/FormEchoHandler.java index 8809f78..9572a00 100644 --- a/src/test/java/com/retailsvc/http/start/FormEchoHandler.java +++ b/src/test/java/com/retailsvc/http/start/FormEchoHandler.java @@ -1,17 +1,16 @@ package com.retailsvc.http.start; +import static java.net.HttpURLConnection.HTTP_OK; + import com.retailsvc.http.Request; import com.retailsvc.http.RequestHandler; -import java.io.IOException; -import java.nio.charset.StandardCharsets; +import com.retailsvc.http.Response; /** Echoes the parsed form body to the response as Map#toString. */ public class FormEchoHandler implements RequestHandler { @Override - public void handle(Request request) throws IOException { - Object parsed = request.parsed(); - byte[] body = String.valueOf(parsed).getBytes(StandardCharsets.UTF_8); - request.respond(200).contentType("text/plain; charset=utf-8").bytes(body); + public Response handle(Request request) { + return Response.text(HTTP_OK, String.valueOf(request.parsed())); } } diff --git a/src/test/java/com/retailsvc/http/start/GetDataHandler.java b/src/test/java/com/retailsvc/http/start/GetDataHandler.java index 505df5d..1e59642 100644 --- a/src/test/java/com/retailsvc/http/start/GetDataHandler.java +++ b/src/test/java/com/retailsvc/http/start/GetDataHandler.java @@ -1,9 +1,11 @@ package com.retailsvc.http.start; +import static java.net.HttpURLConnection.HTTP_OK; + import com.retailsvc.http.Request; import com.retailsvc.http.RequestHandler; -import java.io.IOException; -import java.net.HttpURLConnection; +import com.retailsvc.http.Response; +import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -12,16 +14,8 @@ public class GetDataHandler implements RequestHandler { private static final Logger LOG = LoggerFactory.getLogger(GetDataHandler.class); @Override - public void handle(Request request) throws IOException { + public Response handle(Request request) { LOG.debug("GET /data"); - - byte[] bytes = - """ - { - "id": "some-id" - }\ - """ - .getBytes(); - request.respond(HttpURLConnection.HTTP_OK).contentType("application/json").bytes(bytes); + return Response.of(HTTP_OK, Map.of("id", "some-id")); } } diff --git a/src/test/java/com/retailsvc/http/start/ParamHandler.java b/src/test/java/com/retailsvc/http/start/ParamHandler.java index fd0ba5c..61dc9f0 100644 --- a/src/test/java/com/retailsvc/http/start/ParamHandler.java +++ b/src/test/java/com/retailsvc/http/start/ParamHandler.java @@ -1,8 +1,10 @@ package com.retailsvc.http.start; +import static java.net.HttpURLConnection.HTTP_OK; + import com.retailsvc.http.Request; import com.retailsvc.http.RequestHandler; -import java.io.IOException; +import com.retailsvc.http.Response; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -11,9 +13,8 @@ public class ParamHandler implements RequestHandler { private static final Logger LOG = LoggerFactory.getLogger(ParamHandler.class); @Override - public void handle(Request request) throws IOException { + public Response handle(Request request) { LOG.debug("GET /params"); - - request.respond(200).empty(); + return Response.status(HTTP_OK); } } diff --git a/src/test/java/com/retailsvc/http/start/PostDataHandler.java b/src/test/java/com/retailsvc/http/start/PostDataHandler.java index ae82e5e..479f055 100644 --- a/src/test/java/com/retailsvc/http/start/PostDataHandler.java +++ b/src/test/java/com/retailsvc/http/start/PostDataHandler.java @@ -1,8 +1,10 @@ package com.retailsvc.http.start; +import static java.net.HttpURLConnection.HTTP_OK; + import com.retailsvc.http.Request; import com.retailsvc.http.RequestHandler; -import java.io.IOException; +import com.retailsvc.http.Response; import java.lang.invoke.MethodHandles; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -12,20 +14,14 @@ public class PostDataHandler implements RequestHandler { private static final Logger LOG = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); @Override - public void handle(Request request) throws IOException { + public Response handle(Request request) { LOG.debug("POST /data"); - byte[] bytes = request.bytes(); - if (bytes.length == 0) { LOG.debug("No bytes available to read from the request body"); } else { LOG.debug("Read {} bytes from the request body", bytes.length); } - - String requestBody = new String(bytes); - LOG.debug("Request body: {}", requestBody); - - request.respond(200).contentType("application/json").bytes(requestBody.getBytes()); + return Response.bytes(HTTP_OK, bytes, "application/json"); } } diff --git a/src/test/java/com/retailsvc/http/start/PostListObjectsHandler.java b/src/test/java/com/retailsvc/http/start/PostListObjectsHandler.java index a0c4c75..5ae48b9 100644 --- a/src/test/java/com/retailsvc/http/start/PostListObjectsHandler.java +++ b/src/test/java/com/retailsvc/http/start/PostListObjectsHandler.java @@ -1,8 +1,10 @@ package com.retailsvc.http.start; +import static java.net.HttpURLConnection.HTTP_OK; + import com.retailsvc.http.Request; import com.retailsvc.http.RequestHandler; -import java.io.IOException; +import com.retailsvc.http.Response; import java.lang.invoke.MethodHandles; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -12,20 +14,8 @@ public class PostListObjectsHandler implements RequestHandler { private static final Logger LOG = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); @Override - public void handle(Request request) throws IOException { + public Response handle(Request request) { LOG.debug("POST /list/objects"); - - byte[] bytes = request.bytes(); - - if (bytes.length == 0) { - LOG.debug("No bytes available to read from the request body"); - } else { - LOG.debug("Read {} bytes from the request body", bytes.length); - } - - String requestBody = new String(bytes); - LOG.debug("Request body: {}", requestBody); - - request.respond(200).contentType("application/json").bytes(requestBody.getBytes()); + return Response.bytes(HTTP_OK, request.bytes(), "application/json"); } } diff --git a/src/test/java/com/retailsvc/http/start/TextEchoHandler.java b/src/test/java/com/retailsvc/http/start/TextEchoHandler.java index 0880f90..af0a77f 100644 --- a/src/test/java/com/retailsvc/http/start/TextEchoHandler.java +++ b/src/test/java/com/retailsvc/http/start/TextEchoHandler.java @@ -1,17 +1,16 @@ package com.retailsvc.http.start; +import static java.net.HttpURLConnection.HTTP_OK; + import com.retailsvc.http.Request; import com.retailsvc.http.RequestHandler; -import java.io.IOException; -import java.nio.charset.StandardCharsets; +import com.retailsvc.http.Response; /** Echoes the parsed text/plain body back to the response. */ public class TextEchoHandler implements RequestHandler { @Override - public void handle(Request request) throws IOException { - String parsed = (String) request.parsed(); - byte[] body = parsed.getBytes(StandardCharsets.UTF_8); - request.respond(200).contentType("text/plain; charset=utf-8").bytes(body); + public Response handle(Request request) { + return Response.text(HTTP_OK, (String) request.parsed()); } } From c4a9009d0c35c35c03666288b6ac6020e10938e0 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 14 May 2026 18:43:55 +0200 Subject: [PATCH 33/50] docs: Expand Prerequisites to cover non-JSON specs and TypeMapper choices --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c1405e9..f711a76 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,13 @@ It is designed to be simple to use while providing the essential features needed ## Getting Started ### Prerequisites -- Java SDK 25 or later -- A serialization library, e.g. Gson or Jackson -- OpenAPI specification file in JSON format (`openapi.json`) +- Java SDK 25 or later. +- A JSON library to parse the spec into a `Map`: any of Gson, Jackson, SnakeYAML (for YAML specs), or another mapper of your choice. The library itself doesn't bundle one. +- An OpenAPI 3.1.x specification (`openapi.json` or `openapi.yaml`). +- For `application/json` request/response bodies, either: + - Gson on the classpath — auto-registered via the built-in `GsonJsonMapper` (integer-preserving, JSR-310 written as ISO-8601), or + - a user-supplied `TypeMapper` registered via `Builder.bodyMapper("application/json", mapper)` (e.g. backed by Jackson). +- Built-in mappers for `application/x-www-form-urlencoded` and `text/plain` need no configuration. Any other media type (`application/xml`, `application/cbor`, etc.) requires registering its own `TypeMapper`. ### Basic Usage From 3b8f9897e58d1282874d8d4557b5a984c56c1684 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 14 May 2026 18:50:44 +0200 Subject: [PATCH 34/50] feat: Add Spec.fromPath(Path) with JSON+YAML auto-detect --- README.md | 15 +--- .../java/com/retailsvc/http/spec/Spec.java | 90 +++++++++++++++++++ .../retailsvc/http/spec/SpecFromPathTest.java | 40 +++++++++ 3 files changed, 133 insertions(+), 12 deletions(-) create mode 100644 src/test/java/com/retailsvc/http/spec/SpecFromPathTest.java diff --git a/README.md b/README.md index f711a76..c03f634 100644 --- a/README.md +++ b/README.md @@ -85,12 +85,8 @@ A `null` body always produces a status-only response (`Content-Length: 0`, no bo ``` java public class YourServerLauncher { public static void main(String[] args) throws Exception { - Gson gson = new Gson(); - - // Parse spec to a generic Map (works for JSON; for YAML use SnakeYAML). - String text = Files.readString(Path.of("openapi.json")); - Map raw = (Map) gson.fromJson(text, Map.class); - Spec spec = Spec.from(raw); + // Gson is on the classpath, so we can load the spec in one line. + Spec spec = Spec.fromPath(Path.of("openapi.json")); // Handlers by operationId. Map handlers = new HashMap<>(); @@ -106,12 +102,7 @@ public class YourServerLauncher { } ``` -### YAML specifications -For YAML, replace the JSON parsing line with SnakeYAML: -``` java -Map raw = new Yaml().load(Files.newInputStream(Path.of("openapi.yaml"))); -``` -The rest is identical. +`Spec.fromPath(Path)` picks the parser by file extension: `.json` is parsed by Gson, `.yaml` / `.yml` by SnakeYAML. Both are optional dependencies of this library — the same Gson that powers the built-in JSON `TypeMapper`, and the same SnakeYAML you'd add explicitly to parse YAML. If the required parser isn't on the classpath the call fails with `IllegalStateException`; parse the file yourself and use `Spec.from(Map)` instead. Any other extension is rejected. ### JSON mapping diff --git a/src/main/java/com/retailsvc/http/spec/Spec.java b/src/main/java/com/retailsvc/http/spec/Spec.java index ce6eb20..a9b5fbb 100644 --- a/src/main/java/com/retailsvc/http/spec/Spec.java +++ b/src/main/java/com/retailsvc/http/spec/Spec.java @@ -2,7 +2,12 @@ import com.retailsvc.http.spec.schema.Schema; import com.retailsvc.http.spec.schema.SchemaParser; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.lang.reflect.Method; import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -36,6 +41,91 @@ static Map extractExtensions(Map raw) { return Map.copyOf(out); } + private static final String GSON_CLASS = "com.google.gson.Gson"; + private static final String SNAKEYAML_CLASS = "org.yaml.snakeyaml.Yaml"; + + /** + * Reads an OpenAPI specification from {@code path}. Picks the parser by file extension: + * + *
    + *
  • {@code .json} → Gson must be on the classpath. + *
  • {@code .yaml} or {@code .yml} → SnakeYAML must be on the classpath. + *
+ * + *

Both Gson and SnakeYAML are optional dependencies of this library. If the parser for the + * file's extension is not present, throws {@link IllegalStateException} — register your own + * parser and call {@link #from(Map)} instead. + * + * @throws UncheckedIOException if the file cannot be read + * @throws IllegalStateException if the required parser is not on the classpath, or if the file + * has an unrecognised extension + */ + public static Spec fromPath(Path path) { + String text; + try { + text = Files.readString(path); + } catch (IOException e) { + throw new UncheckedIOException("Failed to read OpenAPI spec from " + path, e); + } + String name = path.getFileName().toString().toLowerCase(Locale.ROOT); + Map raw; + if (name.endsWith(".json")) { + raw = parseJson(text); + } else if (name.endsWith(".yaml") || name.endsWith(".yml")) { + raw = parseYaml(text); + } else { + throw new IllegalStateException( + "Unrecognised OpenAPI spec extension for " + + path + + " — expected .json, .yaml, or .yml. Parse the file yourself and call" + + " Spec.from(Map) instead."); + } + return from(raw); + } + + private static Map parseJson(String text) { + Class gsonClass = loadOptional(GSON_CLASS, "JSON", "Gson"); + try { + Object gson = gsonClass.getDeclaredConstructor().newInstance(); + Method fromJson = gsonClass.getMethod("fromJson", String.class, Class.class); + @SuppressWarnings("unchecked") + Map raw = (Map) fromJson.invoke(gson, text, Map.class); + return raw; + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Failed to parse OpenAPI spec via Gson", e); + } + } + + private static Map parseYaml(String text) { + Class yamlClass = loadOptional(SNAKEYAML_CLASS, "YAML", "SnakeYAML"); + try { + Object yaml = yamlClass.getDeclaredConstructor().newInstance(); + Method load = yamlClass.getMethod("load", String.class); + @SuppressWarnings("unchecked") + Map raw = (Map) load.invoke(yaml, text); + return raw; + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Failed to parse OpenAPI spec via SnakeYAML", e); + } + } + + private static Class loadOptional(String className, String format, String libName) { + try { + return Class.forName(className, false, Spec.class.getClassLoader()); + } catch (ClassNotFoundException e) { + throw new IllegalStateException( + "Spec.fromPath requires " + + libName + + " on the classpath for " + + format + + " specs. Add a " + + libName + + " dependency, or parse the file yourself and call" + + " Spec.from(Map) instead.", + e); + } + } + @SuppressWarnings("unchecked") public static Spec from(Map raw) { String openapi = (String) raw.get("openapi"); diff --git a/src/test/java/com/retailsvc/http/spec/SpecFromPathTest.java b/src/test/java/com/retailsvc/http/spec/SpecFromPathTest.java new file mode 100644 index 0000000..2dda887 --- /dev/null +++ b/src/test/java/com/retailsvc/http/spec/SpecFromPathTest.java @@ -0,0 +1,40 @@ +package com.retailsvc.http.spec; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Path; +import org.junit.jupiter.api.Test; + +class SpecFromPathTest { + + @Test + void loadsJsonSpecViaGson() throws Exception { + Path resource = Path.of(getClass().getResource("/openapi.json").toURI()); + + Spec spec = Spec.fromPath(resource); + + assertThat(spec.openapi()).startsWith("3.1"); + assertThat(spec.basePath()).isEqualTo("/api/v1"); + assertThat(spec.operations()).isNotEmpty(); + } + + @Test + void loadsYamlSpecViaSnakeYaml() throws Exception { + Path resource = Path.of(getClass().getResource("/openapi.yaml").toURI()); + + Spec spec = Spec.fromPath(resource); + + assertThat(spec.openapi()).startsWith("3.1"); + assertThat(spec.operations()).isNotEmpty(); + } + + @Test + void rejectsUnknownExtension(@org.junit.jupiter.api.io.TempDir Path tmp) throws Exception { + Path unknown = tmp.resolve("spec.txt"); + java.nio.file.Files.writeString(unknown, "{}"); + + org.assertj.core.api.Assertions.assertThatThrownBy(() -> Spec.fromPath(unknown)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Unrecognised OpenAPI spec extension"); + } +} From 98a78d31b3951b5fd1faff3dcaae4a3a73106e4d Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 14 May 2026 18:54:08 +0200 Subject: [PATCH 35/50] refactor: Replace inline FQN type references with proper imports --- .../http/DecoratorAndInterceptorIT.java | 6 ++++-- .../com/retailsvc/http/OpenApiServerIT.java | 18 ++++++------------ .../http/TypeMapperRegistrationTest.java | 3 ++- .../retailsvc/http/spec/SpecFromPathTest.java | 9 ++++++--- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/test/java/com/retailsvc/http/DecoratorAndInterceptorIT.java b/src/test/java/com/retailsvc/http/DecoratorAndInterceptorIT.java index b054129..f022666 100644 --- a/src/test/java/com/retailsvc/http/DecoratorAndInterceptorIT.java +++ b/src/test/java/com/retailsvc/http/DecoratorAndInterceptorIT.java @@ -7,8 +7,10 @@ import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.util.List; import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; import org.junit.jupiter.api.Test; class DecoratorAndInterceptorIT extends ServerBaseTest { @@ -66,7 +68,7 @@ void interceptorBindsScopedValueVisibleToHandler() throws Exception { @Test void interceptorsRunInRegistrationOrder() throws Exception { - List trace = new java.util.concurrent.CopyOnWriteArrayList<>(); + List trace = new CopyOnWriteArrayList<>(); RequestHandler ok = req -> { trace.add("handler"); @@ -99,7 +101,7 @@ void interceptorsRunInRegistrationOrder() throws Exception { .containsExactly("outer-before", "inner-before", "handler", "inner-after", "outer-after"); } - private java.net.http.HttpResponse call(String path) throws Exception { + private HttpResponse call(String path) throws Exception { return HttpClient.newHttpClient() .send( HttpRequest.newBuilder() diff --git a/src/test/java/com/retailsvc/http/OpenApiServerIT.java b/src/test/java/com/retailsvc/http/OpenApiServerIT.java index 4190a60..6b6e5e7 100644 --- a/src/test/java/com/retailsvc/http/OpenApiServerIT.java +++ b/src/test/java/com/retailsvc/http/OpenApiServerIT.java @@ -605,8 +605,7 @@ class FormatEmail { @Test void formatEmailShouldReturnBadRequestOnInvalidEmail() { - try (var server = - newServer(Map.of("format-email", req -> com.retailsvc.http.Response.status(200))); + try (var server = newServer(Map.of("format-email", req -> Response.status(200))); var client = httpClient()) { var request = newRequest(server, path + "?addr=not-an-email", "GET", noBody()); @@ -630,8 +629,7 @@ void formatEmailShouldReturnBadRequestOnInvalidEmail() { @Test void formatEmailShouldReturnOkOnValidEmail() { - try (var server = - newServer(Map.of("format-email", req -> com.retailsvc.http.Response.status(200))); + try (var server = newServer(Map.of("format-email", req -> Response.status(200))); var client = httpClient()) { var request = newRequest(server, path + "?addr=user%40example.com", "GET", noBody()); @@ -657,8 +655,7 @@ class FormatByte { @Test void formatByteShouldReturnBadRequestOnInvalidBase64() { - try (var server = - newServer(Map.of("format-byte", req -> com.retailsvc.http.Response.status(200))); + try (var server = newServer(Map.of("format-byte", req -> Response.status(200))); var client = httpClient()) { var request = newRequest(server, path + "?data=not%20base64!!", "GET", noBody()); @@ -682,8 +679,7 @@ void formatByteShouldReturnBadRequestOnInvalidBase64() { @Test void formatByteShouldReturnOkOnValidBase64() { - try (var server = - newServer(Map.of("format-byte", req -> com.retailsvc.http.Response.status(200))); + try (var server = newServer(Map.of("format-byte", req -> Response.status(200))); var client = httpClient()) { var request = newRequest(server, path + "?data=aGVsbG8%3D", "GET", noBody()); @@ -709,8 +705,7 @@ class FormatInt32 { @Test void formatInt32ShouldReturnBadRequestOnOverflow() { - try (var server = - newServer(Map.of("format-int32", req -> com.retailsvc.http.Response.status(200))); + try (var server = newServer(Map.of("format-int32", req -> Response.status(200))); var client = httpClient()) { var request = newRequest(server, path + "?n=2147483648", "GET", noBody()); @@ -734,8 +729,7 @@ void formatInt32ShouldReturnBadRequestOnOverflow() { @Test void formatInt32ShouldReturnOkOnValidValue() { - try (var server = - newServer(Map.of("format-int32", req -> com.retailsvc.http.Response.status(200))); + try (var server = newServer(Map.of("format-int32", req -> Response.status(200))); var client = httpClient()) { var request = newRequest(server, path + "?n=42", "GET", noBody()); diff --git a/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java b/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java index f5e56a3..1e31d17 100644 --- a/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java +++ b/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java @@ -11,6 +11,7 @@ import java.net.http.HttpRequest; import java.net.http.HttpRequest.BodyPublishers; import java.nio.charset.StandardCharsets; +import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; import org.junit.jupiter.api.Test; @@ -59,7 +60,7 @@ void userSuppliedMapperOverridesDefault() throws Exception { @Override public Object readFrom(byte[] b, String h) { readFromCalled.set(true); - return Map.of("aList", java.util.List.of("x"), "feelingGood", true); + return Map.of("aList", List.of("x"), "feelingGood", true); } @Override diff --git a/src/test/java/com/retailsvc/http/spec/SpecFromPathTest.java b/src/test/java/com/retailsvc/http/spec/SpecFromPathTest.java index 2dda887..4542877 100644 --- a/src/test/java/com/retailsvc/http/spec/SpecFromPathTest.java +++ b/src/test/java/com/retailsvc/http/spec/SpecFromPathTest.java @@ -1,9 +1,12 @@ package com.retailsvc.http.spec; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.nio.file.Files; import java.nio.file.Path; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; class SpecFromPathTest { @@ -29,11 +32,11 @@ void loadsYamlSpecViaSnakeYaml() throws Exception { } @Test - void rejectsUnknownExtension(@org.junit.jupiter.api.io.TempDir Path tmp) throws Exception { + void rejectsUnknownExtension(@TempDir Path tmp) throws Exception { Path unknown = tmp.resolve("spec.txt"); - java.nio.file.Files.writeString(unknown, "{}"); + Files.writeString(unknown, "{}"); - org.assertj.core.api.Assertions.assertThatThrownBy(() -> Spec.fromPath(unknown)) + assertThatThrownBy(() -> Spec.fromPath(unknown)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("Unrecognised OpenAPI spec extension"); } From 42ac9119d787549454ab51c134326eb7f669263d Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 14 May 2026 18:58:08 +0200 Subject: [PATCH 36/50] docs: Add end-to-end example using YAML spec, Gson default, one interceptor + decorator --- README.md | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/README.md b/README.md index c03f634..6457b31 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,70 @@ public class GetPromotionHandler implements RequestHandler { } ``` +### End-to-end example + +Gson on the classpath for request/response JSON, SnakeYAML on the classpath for the spec, one interceptor binding a request-scoped tenant + correlation id, one decorator stamping the correlation id on every response, one handler. No extra wiring. + +``` java +package com.example.promotions; + +import static java.net.HttpURLConnection.HTTP_NOT_FOUND; +import static java.net.HttpURLConnection.HTTP_OK; + +import com.retailsvc.http.OpenApiServer; +import com.retailsvc.http.Request; +import com.retailsvc.http.RequestHandler; +import com.retailsvc.http.Response; +import com.retailsvc.http.spec.Spec; +import java.nio.file.Path; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public final class App { + + static final ScopedValue TENANT = ScopedValue.newInstance(); + static final ScopedValue CORRELATION_ID = ScopedValue.newInstance(); + + public static void main(String[] args) throws Exception { + Spec spec = Spec.fromPath(Path.of("openapi.yaml")); // SnakeYAML parses the spec + + RequestHandler getPromotion = req -> { + String id = req.pathParams().get("id"); + return PromotionService.find(TENANT.get(), id) // uses bound tenant + .map(p -> Response.of(HTTP_OK, p)) // 200 + JSON via Gson + .orElseGet(() -> Response.status(HTTP_NOT_FOUND)); // 404, no body + }; + + OpenApiServer.builder() + .spec(spec) + .handlers(Map.of("get-promotion", getPromotion)) + // Bind tenant + correlation id once per request. + .interceptor((req, next) -> { + String tenant = req.header("X-Tenant-Id"); + String correlationId = + Optional.ofNullable(req.header("X-Correlation-Id")) + .orElseGet(() -> UUID.randomUUID().toString()); + return ScopedValue.where(TENANT, tenant) + .where(CORRELATION_ID, correlationId) + .call(next::proceed); + }) + // Stamp the correlation id on every response. + .responseDecorator((req, resp) -> resp.withHeader("X-Correlation-Id", CORRELATION_ID.get())) + .port(8080) + .build(); + } +} +``` + +What the example demonstrates: + +- **Gson is the default JSON serializer.** No explicit `bodyMapper(...)` call — the library auto-registers `GsonJsonMapper` for request and response JSON because Gson is on the classpath. +- **SnakeYAML parses the spec.** `Spec.fromPath(...)` picks the parser by file extension; `.yaml` here means SnakeYAML, and Gson would handle `.json` the same way. +- **One interceptor sets cross-cutting context.** `ScopedValue.where(...).call(next::proceed)` runs the handler (and any inner interceptors and decorators) inside the binding, so `TENANT.get()` and `CORRELATION_ID.get()` work anywhere they're called. +- **One decorator stamps a response header.** `Response.withHeader(...)` is non-destructive — the handler's `Response` is replaced with one that has the extra header. +- **Handler is a pure function.** Reads from `Request`, returns a `Response` value. No `HttpExchange`, no try/catch IOException, no builder. + ### Request body content types The server reads `requestBody.content` from the spec and selects a mapper by the request's media type (the bare `type/subtype` from `Content-Type`, e.g. `application/json`; lookup is case-insensitive): From a17aef787e5a9ea5d8ba11c784aa94eb78d08f74 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 14 May 2026 19:05:36 +0200 Subject: [PATCH 37/50] feat: Add Request.pathParam(name) convenience accessor Mirrors Request.queryParam(name); avoids the round-trip through pathParams().get(name) for the common single-lookup case. --- README.md | 6 +++--- src/main/java/com/retailsvc/http/Request.java | 5 +++++ src/test/java/com/retailsvc/http/RequestTest.java | 9 +++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6457b31..de91992 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ public class PostDataHandler implements RequestHandler { // Or get the already-parsed object (Map / List) produced by the registered TypeMapper. Object parsed = request.parsed(); // Path parameters, query parameters, and headers are also available. - String id = request.pathParams().get("id"); + String id = request.pathParam("id"); String filter = request.queryParam("filter"); String corr = request.header("correlation-id"); @@ -218,7 +218,7 @@ A handler in this setup is just business logic: public class GetPromotionHandler implements RequestHandler { @Override public Response handle(Request request) { - String id = request.pathParams().get("id"); + String id = request.pathParam("id"); String tenant = TENANT_ID.get(); return promotionService .find(tenant, id) @@ -257,7 +257,7 @@ public final class App { Spec spec = Spec.fromPath(Path.of("openapi.yaml")); // SnakeYAML parses the spec RequestHandler getPromotion = req -> { - String id = req.pathParams().get("id"); + String id = req.pathParam("id"); return PromotionService.find(TENANT.get(), id) // uses bound tenant .map(p -> Response.of(HTTP_OK, p)) // 200 + JSON via Gson .orElseGet(() -> Response.status(HTTP_NOT_FOUND)); // 404, no body diff --git a/src/main/java/com/retailsvc/http/Request.java b/src/main/java/com/retailsvc/http/Request.java index 7a06316..e00efc0 100644 --- a/src/main/java/com/retailsvc/http/Request.java +++ b/src/main/java/com/retailsvc/http/Request.java @@ -49,6 +49,11 @@ public Map pathParams() { return pathParameters; } + /** Value of the path parameter {@code name}, or {@code null} if absent. */ + public String pathParam(String name) { + return pathParameters.get(name); + } + public String header(String name) { return exchange.getRequestHeaders().getFirst(name); } diff --git a/src/test/java/com/retailsvc/http/RequestTest.java b/src/test/java/com/retailsvc/http/RequestTest.java index d0da319..9842df5 100644 --- a/src/test/java/com/retailsvc/http/RequestTest.java +++ b/src/test/java/com/retailsvc/http/RequestTest.java @@ -41,6 +41,15 @@ void readsBoundContext() throws Exception { assertThat(seenPathParams.get()).containsEntry("id", "42"); } + @Test + void pathParamReturnsValueOrNull() { + HttpExchange exchange = mock(HttpExchange.class); + Request req = new Request(exchange, new byte[0], null, "op", Map.of("id", "42")); + + assertThat(req.pathParam("id")).isEqualTo("42"); + assertThat(req.pathParam("missing")).isNull(); + } + @Test void exposesQueryParams() { HttpExchange exchange = mock(HttpExchange.class); From 10a05eb3748224ce70e8931b7b877e1010f825f7 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 14 May 2026 19:09:44 +0200 Subject: [PATCH 38/50] feat: Add Response.accepted() / accepted(body) factories --- README.md | 2 ++ src/main/java/com/retailsvc/http/Response.java | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/README.md b/README.md index de91992..468ec9f 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ Response.empty(); // 204 No Content, no body Response.status(404); // 404, no body Response.status(200); // 200 OK, no body Response.ok(Map.of("id", "42")); // 200 OK, JSON body via TypeMapper +Response.accepted(); // 202 Accepted, no body +Response.accepted(Map.of("jobId", "job-42")); // 202 Accepted, JSON body Response.of(201, newResource); // any status, JSON body Response.text(200, "hello"); // text/plain; UTF-8 Response.bytes(200, pdf, "application/pdf"); // pre-serialised bytes diff --git a/src/main/java/com/retailsvc/http/Response.java b/src/main/java/com/retailsvc/http/Response.java index 561ea2f..d465a6a 100644 --- a/src/main/java/com/retailsvc/http/Response.java +++ b/src/main/java/com/retailsvc/http/Response.java @@ -48,6 +48,16 @@ public static Response ok(Object body) { return new Response(200, body, null, Map.of()); } + /** {@code 202 Accepted} with no body. Use for fire-and-forget async work. */ + public static Response accepted() { + return new Response(202, null, null, Map.of()); + } + + /** {@code 202 Accepted} with {@code body} serialised as JSON (typically a job/poll URL). */ + public static Response accepted(Object body) { + return new Response(202, body, null, Map.of()); + } + /** {@code status} with {@code body} serialised by the content-type's {@link TypeMapper}. */ public static Response of(int status, Object body) { return new Response(status, body, null, Map.of()); From 9f18236997122ebeaceb44d633d13c7d9950b14b Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 14 May 2026 19:12:40 +0200 Subject: [PATCH 39/50] feat: Add Response.created/notFound/notImplemented factories --- README.md | 19 +++++++------ .../java/com/retailsvc/http/Response.java | 28 +++++++++++++++++++ 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 468ec9f..82f6a72 100644 --- a/README.md +++ b/README.md @@ -59,12 +59,16 @@ public class PostDataHandler implements RequestHandler { ``` java Response.empty(); // 204 No Content, no body -Response.status(404); // 404, no body Response.status(200); // 200 OK, no body Response.ok(Map.of("id", "42")); // 200 OK, JSON body via TypeMapper +Response.created(newResource); // 201 Created, JSON body +Response.created(newResource, "/things/42"); // 201 Created + Location header Response.accepted(); // 202 Accepted, no body Response.accepted(Map.of("jobId", "job-42")); // 202 Accepted, JSON body -Response.of(201, newResource); // any status, JSON body +Response.notFound(); // 404 Not Found, no body +Response.notFound(problemDetail); // 404 Not Found, JSON body +Response.notImplemented(); // 501 Not Implemented, no body +Response.of(409, conflictDetail); // any status, JSON body Response.text(200, "hello"); // text/plain; UTF-8 Response.bytes(200, pdf, "application/pdf"); // pre-serialised bytes Response.stream(200, "application/octet-stream", // chunked streaming @@ -224,8 +228,8 @@ public class GetPromotionHandler implements RequestHandler { String tenant = TENANT_ID.get(); return promotionService .find(tenant, id) - .map(p -> Response.of(HTTP_OK, p)) - .orElse(Response.status(HTTP_NOT_FOUND)); + .map(Response::ok) + .orElseGet(Response::notFound); } } ``` @@ -237,9 +241,6 @@ Gson on the classpath for request/response JSON, SnakeYAML on the classpath for ``` java package com.example.promotions; -import static java.net.HttpURLConnection.HTTP_NOT_FOUND; -import static java.net.HttpURLConnection.HTTP_OK; - import com.retailsvc.http.OpenApiServer; import com.retailsvc.http.Request; import com.retailsvc.http.RequestHandler; @@ -261,8 +262,8 @@ public final class App { RequestHandler getPromotion = req -> { String id = req.pathParam("id"); return PromotionService.find(TENANT.get(), id) // uses bound tenant - .map(p -> Response.of(HTTP_OK, p)) // 200 + JSON via Gson - .orElseGet(() -> Response.status(HTTP_NOT_FOUND)); // 404, no body + .map(Response::ok) // 200 + JSON via Gson + .orElseGet(Response::notFound); // 404, no body }; OpenApiServer.builder() diff --git a/src/main/java/com/retailsvc/http/Response.java b/src/main/java/com/retailsvc/http/Response.java index d465a6a..8c7f599 100644 --- a/src/main/java/com/retailsvc/http/Response.java +++ b/src/main/java/com/retailsvc/http/Response.java @@ -48,6 +48,19 @@ public static Response ok(Object body) { return new Response(200, body, null, Map.of()); } + /** {@code 201 Created} with {@code body} serialised as JSON. */ + public static Response created(Object body) { + return new Response(201, body, null, Map.of()); + } + + /** + * {@code 201 Created} with {@code body} as JSON and a {@code Location} header — the canonical + * shape for a POST that creates a new resource. + */ + public static Response created(Object body, String location) { + return new Response(201, body, null, Map.of("Location", location)); + } + /** {@code 202 Accepted} with no body. Use for fire-and-forget async work. */ public static Response accepted() { return new Response(202, null, null, Map.of()); @@ -58,6 +71,21 @@ public static Response accepted(Object body) { return new Response(202, body, null, Map.of()); } + /** {@code 404 Not Found} with no body. */ + public static Response notFound() { + return new Response(404, null, null, Map.of()); + } + + /** {@code 404 Not Found} with {@code body} serialised as JSON (e.g. a ProblemDetail). */ + public static Response notFound(Object body) { + return new Response(404, body, null, Map.of()); + } + + /** {@code 501 Not Implemented} with no body. */ + public static Response notImplemented() { + return new Response(501, null, null, Map.of()); + } + /** {@code status} with {@code body} serialised by the content-type's {@link TypeMapper}. */ public static Response of(int status, Object body) { return new Response(status, body, null, Map.of()); From d02cf2d03525cdb6d9d13cd818a18f0724ae134f Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 14 May 2026 19:18:15 +0200 Subject: [PATCH 40/50] refactor: Address Sonar findings (status constants, renderer complexity) --- .../java/com/retailsvc/http/Response.java | 25 ++++-- .../http/internal/ResponseRenderer.java | 83 ++++++++++--------- 2 files changed, 62 insertions(+), 46 deletions(-) diff --git a/src/main/java/com/retailsvc/http/Response.java b/src/main/java/com/retailsvc/http/Response.java index 8c7f599..7355083 100644 --- a/src/main/java/com/retailsvc/http/Response.java +++ b/src/main/java/com/retailsvc/http/Response.java @@ -1,5 +1,12 @@ package com.retailsvc.http; +import static java.net.HttpURLConnection.HTTP_ACCEPTED; +import static java.net.HttpURLConnection.HTTP_CREATED; +import static java.net.HttpURLConnection.HTTP_NOT_FOUND; +import static java.net.HttpURLConnection.HTTP_NOT_IMPLEMENTED; +import static java.net.HttpURLConnection.HTTP_NO_CONTENT; +import static java.net.HttpURLConnection.HTTP_OK; + import com.retailsvc.http.internal.BodyWriter; import java.io.IOException; import java.io.OutputStream; @@ -33,7 +40,7 @@ public record Response(int status, Object body, String contentType, Map mappers; @@ -31,47 +32,55 @@ public void render(HttpExchange exchange, Response response) throws IOException if (body == null) { exchange.sendResponseHeaders(status, -1); - return; + } else if (body instanceof BodyWriter writer) { + renderStream(exchange, headers, status, response.contentType(), writer); + } else { + renderBytes(exchange, headers, status, response.contentType(), body); } + } + } - if (body instanceof BodyWriter writer) { - long length = writer instanceof BodyWriter.Sized sized ? sized.length() : 0; - if (response.contentType() != null && !headers.containsKey(CONTENT_TYPE)) { - headers.add(CONTENT_TYPE, response.contentType()); - } - exchange.sendResponseHeaders(status, length); - try (OutputStream out = exchange.getResponseBody()) { - writer.writeTo(out); - } - return; - } + private static void renderStream( + HttpExchange exchange, Headers headers, int status, String contentType, BodyWriter writer) + throws IOException { + if (contentType != null && !headers.containsKey(CONTENT_TYPE)) { + headers.add(CONTENT_TYPE, contentType); + } + long length = writer instanceof BodyWriter.Sized sized ? sized.length() : 0; + exchange.sendResponseHeaders(status, length); + try (OutputStream out = exchange.getResponseBody()) { + writer.writeTo(out); + } + } - byte[] bytes; - String contentType = response.contentType(); - if (body instanceof byte[] raw) { - bytes = raw; - if (contentType == null) { - contentType = "application/octet-stream"; - } - } else { - if (contentType == null) { - contentType = DEFAULT_JSON; - } - TypeMapper mapper = mappers.get(contentType.toLowerCase(Locale.ROOT)); - if (mapper == null) { - throw new IllegalStateException("No TypeMapper registered for " + contentType); - } - bytes = mapper.writeTo(body); - } - if (!headers.containsKey(CONTENT_TYPE)) { - headers.add(CONTENT_TYPE, contentType); - } - exchange.sendResponseHeaders(status, bytes.length == 0 ? -1 : bytes.length); - if (bytes.length > 0) { - try (OutputStream out = exchange.getResponseBody()) { - out.write(bytes); - } + private void renderBytes( + HttpExchange exchange, Headers headers, int status, String contentType, Object body) + throws IOException { + byte[] bytes; + String effectiveContentType; + if (body instanceof byte[] raw) { + bytes = raw; + effectiveContentType = contentType != null ? contentType : OCTET_STREAM; + } else { + effectiveContentType = contentType != null ? contentType : DEFAULT_JSON; + bytes = serialize(body, effectiveContentType); + } + if (!headers.containsKey(CONTENT_TYPE)) { + headers.add(CONTENT_TYPE, effectiveContentType); + } + exchange.sendResponseHeaders(status, bytes.length == 0 ? -1 : bytes.length); + if (bytes.length > 0) { + try (OutputStream out = exchange.getResponseBody()) { + out.write(bytes); } } } + + private byte[] serialize(Object body, String contentType) { + TypeMapper mapper = mappers.get(contentType.toLowerCase(Locale.ROOT)); + if (mapper == null) { + throw new IllegalStateException("No TypeMapper registered for " + contentType); + } + return mapper.writeTo(body); + } } From 6a1874d5eafaf8b7289e4a823c1366bab7627c72 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 14 May 2026 19:35:07 +0200 Subject: [PATCH 41/50] feat: Add JacksonJsonTypeMapper adapter for ObjectMapper-backed JSON mapping --- README.md | 15 +++- pom.xml | 6 ++ .../retailsvc/http/JacksonJsonTypeMapper.java | 52 +++++++++++++ .../http/JacksonJsonTypeMapperTest.java | 50 +++++++++++++ .../java/com/retailsvc/http/ResponseTest.java | 74 +++++++++++++++++++ 5 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/retailsvc/http/JacksonJsonTypeMapper.java create mode 100644 src/test/java/com/retailsvc/http/JacksonJsonTypeMapperTest.java create mode 100644 src/test/java/com/retailsvc/http/ResponseTest.java diff --git a/README.md b/README.md index 82f6a72..741a900 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ It is designed to be simple to use while providing the essential features needed - An OpenAPI 3.1.x specification (`openapi.json` or `openapi.yaml`). - For `application/json` request/response bodies, either: - Gson on the classpath — auto-registered via the built-in `GsonJsonMapper` (integer-preserving, JSR-310 written as ISO-8601), or - - a user-supplied `TypeMapper` registered via `Builder.bodyMapper("application/json", mapper)` (e.g. backed by Jackson). + - Jackson via the built-in `JacksonJsonTypeMapper(ObjectMapper)` adapter (caller supplies a configured `ObjectMapper`), or + - any other `TypeMapper` you register via `Builder.bodyMapper("application/json", mapper)`. - Built-in mappers for `application/x-www-form-urlencoded` and `text/plain` need no configuration. Any other media type (`application/xml`, `application/cbor`, etc.) requires registering its own `TypeMapper`. @@ -117,17 +118,23 @@ The library ships an internal `GsonJsonMapper` that is auto-registered for `appl - Returns JSON integers as `Long` and fractional numbers as `Double`. - Writes JSR-310 types (`Instant`, `OffsetDateTime`, `ZonedDateTime`, `LocalDateTime`, `LocalDate`, `LocalTime`) as ISO-8601 strings. -For non-ISO date formats, custom naming strategies, or other custom serialization, register your own `TypeMapper`: +For Jackson, the library ships a `JacksonJsonTypeMapper` adapter that wraps an `ObjectMapper` you configure (modules, naming strategy, JSR-310, date formats — all your call): ``` java +ObjectMapper objectMapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + var server = OpenApiServer.builder() .spec(spec) - .bodyMapper("application/json", new MyCustomJsonMapper()) + .bodyMapper("application/json", new JacksonJsonTypeMapper(objectMapper)) .handlers(handlers) .build(); ``` -If Gson is not on the classpath and no `application/json` mapper is registered, `build()` throws `IllegalStateException`. +The same shape applies to any custom mapper — implement `TypeMapper` and register it. + +If neither Gson is on the classpath nor any `application/json` mapper is registered, `build()` throws `IllegalStateException`. ### Body parsers and response writers diff --git a/pom.xml b/pom.xml index b261711..b69351b 100644 --- a/pom.xml +++ b/pom.xml @@ -38,6 +38,12 @@ + + com.fasterxml.jackson.core + jackson-databind + 2.21.3 + true + com.google.code.gson gson diff --git a/src/main/java/com/retailsvc/http/JacksonJsonTypeMapper.java b/src/main/java/com/retailsvc/http/JacksonJsonTypeMapper.java new file mode 100644 index 0000000..2369d3e --- /dev/null +++ b/src/main/java/com/retailsvc/http/JacksonJsonTypeMapper.java @@ -0,0 +1,52 @@ +package com.retailsvc.http; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Objects; + +/** + * {@link TypeMapper} for {@code application/json} backed by Jackson. The caller supplies a + * fully-configured {@link ObjectMapper}; this class never adds modules or changes settings — the + * mapper you pass is the mapper you get. + * + *

Typical wiring: + * + *

{@code
+ * OpenApiServer.builder()
+ *     .spec(spec)
+ *     .bodyMapper("application/json", new JacksonJsonTypeMapper(myObjectMapper))
+ *     .handlers(handlers)
+ *     .build();
+ * }
+ * + *

Jackson is an optional Maven dependency of this library; consumers that use Jackson + * must declare {@code jackson-databind} themselves. Consumers that use Gson can rely on the + * built-in {@code GsonJsonMapper} auto-fallback instead. + */ +public final class JacksonJsonTypeMapper implements TypeMapper { + + private final ObjectMapper mapper; + + public JacksonJsonTypeMapper(ObjectMapper mapper) { + this.mapper = Objects.requireNonNull(mapper, "mapper must not be null"); + } + + @Override + public Object readFrom(byte[] body, String contentTypeHeader) { + try { + return mapper.readValue(body, Object.class); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public byte[] writeTo(Object value) { + try { + return mapper.writeValueAsBytes(value); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/src/test/java/com/retailsvc/http/JacksonJsonTypeMapperTest.java b/src/test/java/com/retailsvc/http/JacksonJsonTypeMapperTest.java new file mode 100644 index 0000000..1c2ae0f --- /dev/null +++ b/src/test/java/com/retailsvc/http/JacksonJsonTypeMapperTest.java @@ -0,0 +1,50 @@ +package com.retailsvc.http; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class JacksonJsonTypeMapperTest { + + private final JacksonJsonTypeMapper mapper = new JacksonJsonTypeMapper(new ObjectMapper()); + + @Test + void readsJsonObjectAsMap() { + byte[] body = "{\"n\":42,\"s\":\"hi\",\"a\":[1,2]}".getBytes(StandardCharsets.UTF_8); + + Object parsed = mapper.readFrom(body, "application/json"); + + assertThat(parsed).isInstanceOf(Map.class); + @SuppressWarnings("unchecked") + Map m = (Map) parsed; + assertThat(m).containsEntry("n", 42).containsEntry("s", "hi").containsEntry("a", List.of(1, 2)); + } + + @Test + void writesMapAsJson() { + byte[] out = mapper.writeTo(Map.of("k", "v")); + + assertThat(new String(out, StandardCharsets.UTF_8)).isEqualTo("{\"k\":\"v\"}"); + } + + @Test + void wrapsReadFailureAsUncheckedIOException() { + byte[] malformed = "not json".getBytes(StandardCharsets.UTF_8); + + assertThatThrownBy(() -> mapper.readFrom(malformed, "application/json")) + .isInstanceOf(UncheckedIOException.class); + } + + @Test + void rejectsNullObjectMapper() { + assertThatThrownBy(() -> new JacksonJsonTypeMapper(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("mapper"); + } +} diff --git a/src/test/java/com/retailsvc/http/ResponseTest.java b/src/test/java/com/retailsvc/http/ResponseTest.java new file mode 100644 index 0000000..12d7ff6 --- /dev/null +++ b/src/test/java/com/retailsvc/http/ResponseTest.java @@ -0,0 +1,74 @@ +package com.retailsvc.http; + +import static java.net.HttpURLConnection.HTTP_ACCEPTED; +import static java.net.HttpURLConnection.HTTP_CREATED; +import static java.net.HttpURLConnection.HTTP_NOT_FOUND; +import static java.net.HttpURLConnection.HTTP_NOT_IMPLEMENTED; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ResponseTest { + + @Test + void acceptedNoBody() { + Response r = Response.accepted(); + + assertThat(r.status()).isEqualTo(HTTP_ACCEPTED); + assertThat(r.body()).isNull(); + assertThat(r.headers()).isEmpty(); + } + + @Test + void acceptedWithBody() { + Map job = Map.of("id", "job-42"); + Response r = Response.accepted(job); + + assertThat(r.status()).isEqualTo(HTTP_ACCEPTED); + assertThat(r.body()).isEqualTo(job); + } + + @Test + void createdWithBody() { + Map resource = Map.of("id", "x-1"); + Response r = Response.created(resource); + + assertThat(r.status()).isEqualTo(HTTP_CREATED); + assertThat(r.body()).isEqualTo(resource); + assertThat(r.headers()).isEmpty(); + } + + @Test + void createdWithLocation() { + Response r = Response.created(Map.of("id", "x-1"), "/things/x-1"); + + assertThat(r.status()).isEqualTo(HTTP_CREATED); + assertThat(r.headers()).containsEntry("Location", "/things/x-1"); + } + + @Test + void notFoundNoBody() { + Response r = Response.notFound(); + + assertThat(r.status()).isEqualTo(HTTP_NOT_FOUND); + assertThat(r.body()).isNull(); + } + + @Test + void notFoundWithBody() { + Map problem = Map.of("title", "Missing"); + Response r = Response.notFound(problem); + + assertThat(r.status()).isEqualTo(HTTP_NOT_FOUND); + assertThat(r.body()).isEqualTo(problem); + } + + @Test + void notImplementedNoBody() { + Response r = Response.notImplemented(); + + assertThat(r.status()).isEqualTo(HTTP_NOT_IMPLEMENTED); + assertThat(r.body()).isNull(); + } +} From d7c36941bb7840077c9a0e5e9aaa7092b6c6755d Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 14 May 2026 19:48:51 +0200 Subject: [PATCH 42/50] feat: Add TypedTypeMapper + Request.asPojo(Class) for direct POJO deserialisation --- README.md | 10 ++- .../retailsvc/http/JacksonJsonTypeMapper.java | 12 ++- src/main/java/com/retailsvc/http/Request.java | 38 +++++++++ .../com/retailsvc/http/TypedTypeMapper.java | 22 +++++ .../internal/RequestPreparationFilter.java | 23 ++++-- .../java/com/retailsvc/http/RequestTest.java | 80 ++++++++++++++++++- .../http/internal/DispatchHandlerTest.java | 2 +- 7 files changed, 170 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/retailsvc/http/TypedTypeMapper.java diff --git a/README.md b/README.md index 741a900..2cd077c 100644 --- a/README.md +++ b/README.md @@ -36,20 +36,22 @@ It is designed to be simple to use while providing the essential features needed // Inline lambda — returns JSON using the built-in Gson mapper. RequestHandler getDataHandler = req -> Response.ok(Map.of("id", "some-id")); -// Class form — reads raw bytes or the pre-parsed body object. +// Class form — reads raw bytes, the loose Map view, or a typed POJO. public class PostDataHandler implements RequestHandler { @Override public Response handle(Request request) { // Access the raw request body bytes. byte[] body = request.bytes(); - // Or get the already-parsed object (Map / List) produced by the registered TypeMapper. + // Loose structural view (Map / List / boxed primitives), produced by the registered TypeMapper. Object parsed = request.parsed(); + // Or, when the JSON mapper is Jackson (a TypedTypeMapper), get a typed POJO directly. + MyDto dto = request.asPojo(MyDto.class); // Path parameters, query parameters, and headers are also available. String id = request.pathParam("id"); String filter = request.queryParam("filter"); String corr = request.header("correlation-id"); - return Response.ok(parsed); + return Response.ok(dto); } } ``` @@ -132,7 +134,7 @@ var server = OpenApiServer.builder() .build(); ``` -The same shape applies to any custom mapper — implement `TypeMapper` and register it. +The same shape applies to any custom mapper — implement `TypeMapper` (and optionally `TypedTypeMapper` if you can deserialise directly into a target type, so handlers can call `request.asPojo(MyDto.class)`). If neither Gson is on the classpath nor any `application/json` mapper is registered, `build()` throws `IllegalStateException`. diff --git a/src/main/java/com/retailsvc/http/JacksonJsonTypeMapper.java b/src/main/java/com/retailsvc/http/JacksonJsonTypeMapper.java index 2369d3e..dd95abb 100644 --- a/src/main/java/com/retailsvc/http/JacksonJsonTypeMapper.java +++ b/src/main/java/com/retailsvc/http/JacksonJsonTypeMapper.java @@ -10,6 +10,9 @@ * fully-configured {@link ObjectMapper}; this class never adds modules or changes settings — the * mapper you pass is the mapper you get. * + *

Implements {@link TypedTypeMapper}, so handlers can ask for a typed view of the body via + * {@link Request#parsed(Class)}. + * *

Typical wiring: * *

{@code
@@ -24,7 +27,7 @@
  * must declare {@code jackson-databind} themselves. Consumers that use Gson can rely on the
  * built-in {@code GsonJsonMapper} auto-fallback instead.
  */
-public final class JacksonJsonTypeMapper implements TypeMapper {
+public final class JacksonJsonTypeMapper implements TypedTypeMapper {
 
   private final ObjectMapper mapper;
 
@@ -34,8 +37,13 @@ public JacksonJsonTypeMapper(ObjectMapper mapper) {
 
   @Override
   public Object readFrom(byte[] body, String contentTypeHeader) {
+    return readAs(body, contentTypeHeader, Object.class);
+  }
+
+  @Override
+  public  T readAs(byte[] body, String contentTypeHeader, Class type) {
     try {
-      return mapper.readValue(body, Object.class);
+      return mapper.readValue(body, type);
     } catch (IOException e) {
       throw new UncheckedIOException(e);
     }
diff --git a/src/main/java/com/retailsvc/http/Request.java b/src/main/java/com/retailsvc/http/Request.java
index e00efc0..c7057a7 100644
--- a/src/main/java/com/retailsvc/http/Request.java
+++ b/src/main/java/com/retailsvc/http/Request.java
@@ -5,6 +5,7 @@
 import java.nio.charset.StandardCharsets;
 import java.util.LinkedHashMap;
 import java.util.Map;
+import java.util.Objects;
 
 /**
  * Read-only per-request handle passed to {@link RequestHandler}. Carries the parsed body, path
@@ -13,9 +14,12 @@
  */
 public final class Request {
 
+  private static final String CONTENT_TYPE = "Content-Type";
+
   private final HttpExchange exchange;
   private final byte[] body;
   private final Object parsed;
+  private final TypeMapper bodyMapper;
   private final String operationId;
   private final Map pathParameters;
   private Map queryParamCache;
@@ -24,11 +28,13 @@ public Request(
       HttpExchange exchange,
       byte[] body,
       Object parsed,
+      TypeMapper bodyMapper,
       String operationId,
       Map pathParameters) {
     this.exchange = exchange;
     this.body = body;
     this.parsed = parsed;
+    this.bodyMapper = bodyMapper;
     this.operationId = operationId;
     this.pathParameters = pathParameters;
   }
@@ -37,10 +43,42 @@ public byte[] bytes() {
     return body;
   }
 
+  /**
+   * Loose structural view of the body (typically a {@code Map} / {@code List} / boxed primitive).
+   */
   public Object parsed() {
     return parsed;
   }
 
+  /**
+   * Typed view of the body, deserialised into {@code type} by the request's body mapper.
+   *
+   * 

Requires the registered {@link TypeMapper} for the request's {@code Content-Type} to + * implement {@link TypedTypeMapper} — Jackson does, the built-in form and text mappers do not. If + * the loose {@link #parsed()} value already is an instance of {@code type}, it is returned + * directly without re-deserialising. + * + * @throws NullPointerException if {@code type} is null + * @throws IllegalStateException if there is no body, or if the body mapper does not implement + * {@link TypedTypeMapper} + */ + public T asPojo(Class type) { + Objects.requireNonNull(type, "type must not be null"); + if (body == null || body.length == 0) { + throw new IllegalStateException("request has no body"); + } + if (parsed != null && type.isInstance(parsed)) { + return type.cast(parsed); + } + if (bodyMapper instanceof TypedTypeMapper typed) { + return typed.readAs(body, header(CONTENT_TYPE), type); + } + throw new IllegalStateException( + "body mapper for " + + header(CONTENT_TYPE) + + " does not support typed conversion; the mapper must implement TypedTypeMapper"); + } + public String operationId() { return operationId; } diff --git a/src/main/java/com/retailsvc/http/TypedTypeMapper.java b/src/main/java/com/retailsvc/http/TypedTypeMapper.java new file mode 100644 index 0000000..78126c0 --- /dev/null +++ b/src/main/java/com/retailsvc/http/TypedTypeMapper.java @@ -0,0 +1,22 @@ +package com.retailsvc.http; + +/** + * Optional capability for {@link TypeMapper}s that can deserialise a request body directly into a + * caller-supplied target type. The framework uses this when handlers call {@link + * Request#asPojo(Class)}; mappers that cannot meaningfully honour a target type (e.g. the built-in + * form / text mappers) should not implement this interface. + * + *

Implementations should wrap any underlying {@link java.io.IOException} as a {@link + * java.io.UncheckedIOException} — consistent with the surrounding {@link TypeMapper} contract. + */ +public interface TypedTypeMapper extends TypeMapper { + + /** + * Deserialise {@code body} into an instance of {@code type}. + * + * @param body raw request body bytes + * @param contentTypeHeader the full raw {@code Content-Type} header (for charset / params) + * @param type the target type + */ + T readAs(byte[] body, String contentTypeHeader, Class type); +} diff --git a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java index 18657a9..f0e3443 100644 --- a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java +++ b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java @@ -62,10 +62,16 @@ public void doFilter(HttpExchange exchange, Chain chain) throws IOException { Operation op = match.operation(); validateParameters(exchange, op, match.pathParameters()); - Object parsedBody = validateAndParseBody(exchange, op, body); + ParsedBody parsedBody = validateAndParseBody(exchange, op, body); Request request = - new Request(exchange, body, parsedBody, op.operationId(), match.pathParameters()); + new Request( + exchange, + body, + parsedBody.value(), + parsedBody.mapper(), + op.operationId(), + match.pathParameters()); try { ScopedValue.where(DispatchHandler.CURRENT, request) @@ -119,17 +125,22 @@ private void validateParameters( } } - private Object validateAndParseBody(HttpExchange exchange, Operation op, byte[] body) { + /** Result of {@link #validateAndParseBody}: parsed payload plus the mapper that produced it. */ + private record ParsedBody(Object value, TypeMapper mapper) { + static final ParsedBody EMPTY = new ParsedBody(null, null); + } + + private ParsedBody validateAndParseBody(HttpExchange exchange, Operation op, byte[] body) { Optional rb = op.requestBody(); if (rb.isEmpty()) { - return null; + return ParsedBody.EMPTY; } if (body.length == 0) { if (rb.get().required()) { throw new ValidationException( new ValidationError(BODY_POINTER, "required", "request body is required", null)); } - return null; + return ParsedBody.EMPTY; } String header = exchange.getRequestHeaders().getFirst("Content-Type"); String mediaType = ContentTypeHeader.mediaType(header); @@ -152,7 +163,7 @@ private Object validateAndParseBody(HttpExchange exchange, Operation op, byte[] parsed = FormBodyCoercion.coerce(typed, mt.schema()); } validator.validate(parsed, mt.schema(), ""); - return parsed; + return new ParsedBody(parsed, mapper); } private static Map parseQuery(String query) { diff --git a/src/test/java/com/retailsvc/http/RequestTest.java b/src/test/java/com/retailsvc/http/RequestTest.java index 9842df5..2d6e859 100644 --- a/src/test/java/com/retailsvc/http/RequestTest.java +++ b/src/test/java/com/retailsvc/http/RequestTest.java @@ -1,12 +1,16 @@ package com.retailsvc.http; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import com.fasterxml.jackson.databind.ObjectMapper; import com.retailsvc.http.internal.DispatchHandler; +import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpExchange; import java.net.URI; +import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; @@ -17,7 +21,8 @@ class RequestTest { void readsBoundContext() throws Exception { HttpExchange exchange = mock(HttpExchange.class); Request req = - new Request(exchange, new byte[] {1, 2, 3}, Map.of("k", "v"), "get-x", Map.of("id", "42")); + new Request( + exchange, new byte[] {1, 2, 3}, Map.of("k", "v"), null, "get-x", Map.of("id", "42")); AtomicReference seenBytes = new AtomicReference<>(); AtomicReference seenParsed = new AtomicReference<>(); @@ -41,10 +46,77 @@ void readsBoundContext() throws Exception { assertThat(seenPathParams.get()).containsEntry("id", "42"); } + @Test + void asPojoDeserialisesViaTypedMapper() { + HttpExchange exchange = mock(HttpExchange.class); + Headers headers = new Headers(); + headers.add("Content-Type", "application/json"); + when(exchange.getRequestHeaders()).thenReturn(headers); + JacksonJsonTypeMapper mapper = new JacksonJsonTypeMapper(new ObjectMapper()); + byte[] body = "{\"id\":\"x-1\",\"qty\":7}".getBytes(StandardCharsets.UTF_8); + Request req = + new Request(exchange, body, Map.of("id", "x-1", "qty", 7), mapper, "op", Map.of()); + + Item item = req.asPojo(Item.class); + + assertThat(item.id).isEqualTo("x-1"); + assertThat(item.qty).isEqualTo(7); + } + + @Test + void asPojoFastPathWhenParsedAlreadyMatchesType() { + HttpExchange exchange = mock(HttpExchange.class); + Map alreadyParsed = Map.of("k", "v"); + Request req = new Request(exchange, "x".getBytes(), alreadyParsed, null, "op", Map.of()); + + Map result = req.asPojo(Map.class); + assertThat(result).isSameAs(alreadyParsed); + } + + @Test + void asPojoThrowsWhenBodyEmpty() { + HttpExchange exchange = mock(HttpExchange.class); + Request req = new Request(exchange, new byte[0], null, null, "op", Map.of()); + + assertThatThrownBy(() -> req.asPojo(Item.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("no body"); + } + + @Test + void asPojoThrowsWhenMapperNotTyped() { + HttpExchange exchange = mock(HttpExchange.class); + Headers headers = new Headers(); + headers.add("Content-Type", "text/plain"); + when(exchange.getRequestHeaders()).thenReturn(headers); + TypeMapper plain = + new TypeMapper() { + @Override + public Object readFrom(byte[] b, String h) { + return new String(b, StandardCharsets.UTF_8); + } + + @Override + public byte[] writeTo(Object v) { + return v.toString().getBytes(StandardCharsets.UTF_8); + } + }; + Request req = new Request(exchange, "hello".getBytes(), "hello", plain, "op", Map.of()); + + assertThatThrownBy(() -> req.asPojo(Item.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("TypedTypeMapper"); + } + + static final class Item { + public String id; + public int qty; + } + @Test void pathParamReturnsValueOrNull() { HttpExchange exchange = mock(HttpExchange.class); - Request req = new Request(exchange, new byte[0], null, "op", Map.of("id", "42")); + Request req = new Request(exchange, new byte[0], null, null, "op", Map.of("id", "42")); assertThat(req.pathParam("id")).isEqualTo("42"); assertThat(req.pathParam("missing")).isNull(); @@ -55,7 +127,7 @@ void exposesQueryParams() { HttpExchange exchange = mock(HttpExchange.class); when(exchange.getRequestURI()) .thenReturn(URI.create("http://h/x?name=Alice%20Smith&active=true&active=false")); - Request req = new Request(exchange, new byte[0], null, "op", Map.of()); + Request req = new Request(exchange, new byte[0], null, null, "op", Map.of()); assertThat(req.rawQuery()).isEqualTo("name=Alice%20Smith&active=true&active=false"); assertThat(req.queryParam("name")).isEqualTo("Alice Smith"); @@ -70,7 +142,7 @@ void exposesQueryParams() { void queryParamsEmptyWhenNoQuery() { HttpExchange exchange = mock(HttpExchange.class); when(exchange.getRequestURI()).thenReturn(URI.create("http://h/x")); - Request req = new Request(exchange, new byte[0], null, "op", Map.of()); + Request req = new Request(exchange, new byte[0], null, null, "op", Map.of()); assertThat(req.rawQuery()).isNull(); assertThat(req.queryParams()).isEmpty(); diff --git a/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java b/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java index 5b89897..466068a 100644 --- a/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java +++ b/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java @@ -32,7 +32,7 @@ private static DispatchHandler dispatcher(Map handlers) private static void withRequest( HttpExchange exchange, String operationId, ScopedValue.CallableOp body) throws Exception { - Request req = new Request(exchange, new byte[0], null, operationId, Map.of()); + Request req = new Request(exchange, new byte[0], null, null, operationId, Map.of()); ScopedValue.where(DispatchHandler.CURRENT, req).call(body); } From 1813bc7034a1b28c75f3768e546013f8937db263 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 14 May 2026 19:52:22 +0200 Subject: [PATCH 43/50] feat: GsonJsonMapper implements TypedTypeMapper and round-trips JSR-310 --- README.md | 8 +-- .../http/internal/gson/GsonJsonMapper.java | 52 +++++++++++++------ .../internal/gson/GsonJsonMapperTest.java | 30 +++++++++++ 3 files changed, 70 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 2cd077c..08d1ed8 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,8 @@ public class PostDataHandler implements RequestHandler { byte[] body = request.bytes(); // Loose structural view (Map / List / boxed primitives), produced by the registered TypeMapper. Object parsed = request.parsed(); - // Or, when the JSON mapper is Jackson (a TypedTypeMapper), get a typed POJO directly. + // Or get a typed POJO directly (works with the Gson and Jackson built-ins; both implement + // TypedTypeMapper). MyDto dto = request.asPojo(MyDto.class); // Path parameters, query parameters, and headers are also available. String id = request.pathParam("id"); @@ -117,8 +118,9 @@ public class YourServerLauncher { The library ships an internal `GsonJsonMapper` that is auto-registered for `application/json` when Gson is on the classpath and no user-supplied JSON mapper has been registered. It: -- Returns JSON integers as `Long` and fractional numbers as `Double`. -- Writes JSR-310 types (`Instant`, `OffsetDateTime`, `ZonedDateTime`, `LocalDateTime`, `LocalDate`, `LocalTime`) as ISO-8601 strings. +- Returns JSON integers as `Long` and fractional numbers as `Double` for the loose `request.parsed()` view. +- For `request.asPojo(MyDto.class)`, delegates to Gson — the target type's fields determine the Java types (`int`, `long`, `Instant`, etc.). +- Round-trips JSR-310 types (`Instant`, `OffsetDateTime`, `ZonedDateTime`, `LocalDateTime`, `LocalDate`, `LocalTime`) as their ISO-8601 string form. For Jackson, the library ships a `JacksonJsonTypeMapper` adapter that wraps an `ObjectMapper` you configure (modules, naming strategy, JSR-310, date formats — all your call): diff --git a/src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java b/src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java index f723aab..72101e4 100644 --- a/src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java +++ b/src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java @@ -10,8 +10,9 @@ import com.google.gson.JsonPrimitive; import com.google.gson.TypeAdapter; import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; import com.google.gson.stream.JsonWriter; -import com.retailsvc.http.TypeMapper; +import com.retailsvc.http.TypedTypeMapper; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.time.Instant; @@ -27,28 +28,36 @@ import java.util.function.Function; /** - * Built-in {@link TypeMapper} for {@code application/json} backed by Gson. Auto-registered by + * Built-in {@link TypedTypeMapper} for {@code application/json} backed by Gson. Auto-registered by * {@link com.retailsvc.http.OpenApiServer.Builder} when Gson is on the classpath and no * user-supplied JSON mapper has been registered. * - *

JSON numbers without a decimal point or exponent are returned as {@code Long}; fractional - * numbers are returned as {@code Double}. JSR-310 types ({@code Instant}, {@code OffsetDateTime}, - * {@code ZonedDateTime}, {@code LocalDateTime}, {@code LocalDate}, {@code LocalTime}) are written - * as their ISO-8601 string form. + *

The loose {@link #readFrom(byte[], String)} path returns JSON numbers without a decimal point + * or exponent as {@code Long} and fractional numbers as {@code Double}. The typed {@link + * #readAs(byte[], String, Class)} path delegates to Gson, so target-type fields determine the + * resulting Java types (an {@code int} field gets an {@code int}, an {@code Instant} field gets an + * {@code Instant}, etc.). + * + *

JSR-310 types ({@code Instant}, {@code OffsetDateTime}, {@code ZonedDateTime}, {@code + * LocalDateTime}, {@code LocalDate}, {@code LocalTime}) are round-tripped as their ISO-8601 string + * form. */ -public final class GsonJsonMapper implements TypeMapper { +public final class GsonJsonMapper implements TypedTypeMapper { private final Gson gson; public GsonJsonMapper() { this.gson = new GsonBuilder() - .registerTypeAdapter(Instant.class, isoStringWriter(Instant::toString)) - .registerTypeAdapter(OffsetDateTime.class, isoStringWriter(OffsetDateTime::toString)) - .registerTypeAdapter(ZonedDateTime.class, isoStringWriter(ZonedDateTime::toString)) - .registerTypeAdapter(LocalDateTime.class, isoStringWriter(LocalDateTime::toString)) - .registerTypeAdapter(LocalDate.class, isoStringWriter(LocalDate::toString)) - .registerTypeAdapter(LocalTime.class, isoStringWriter(LocalTime::toString)) + .registerTypeAdapter(Instant.class, iso(Instant::toString, Instant::parse)) + .registerTypeAdapter( + OffsetDateTime.class, iso(OffsetDateTime::toString, OffsetDateTime::parse)) + .registerTypeAdapter( + ZonedDateTime.class, iso(ZonedDateTime::toString, ZonedDateTime::parse)) + .registerTypeAdapter( + LocalDateTime.class, iso(LocalDateTime::toString, LocalDateTime::parse)) + .registerTypeAdapter(LocalDate.class, iso(LocalDate::toString, LocalDate::parse)) + .registerTypeAdapter(LocalTime.class, iso(LocalTime::toString, LocalTime::parse)) .create(); } @@ -58,6 +67,11 @@ public Object readFrom(byte[] body, String contentTypeHeader) { return toJavaObject(element); } + @Override + public T readAs(byte[] body, String contentTypeHeader, Class type) { + return gson.fromJson(new String(body, StandardCharsets.UTF_8), type); + } + @Override public byte[] writeTo(Object value) { return gson.toJson(value).getBytes(StandardCharsets.UTF_8); @@ -120,7 +134,8 @@ private static Object toNumber(String raw) { return Double.parseDouble(raw); } - private static TypeAdapter isoStringWriter(Function toIso) { + /** Round-trips a JSR-310 type as an ISO-8601 string. */ + private static TypeAdapter iso(Function toIso, Function fromIso) { return new TypeAdapter() { @Override public void write(JsonWriter out, T value) throws IOException { @@ -132,9 +147,12 @@ public void write(JsonWriter out, T value) throws IOException { } @Override - public T read(JsonReader in) { - throw new UnsupportedOperationException( - "GsonJsonMapper does not parse JSR-310 types; values arrive as String"); + public T read(JsonReader in) throws IOException { + if (in.peek() == JsonToken.NULL) { + in.nextNull(); + return null; + } + return fromIso.apply(in.nextString()); } }; } diff --git a/src/test/java/com/retailsvc/http/internal/gson/GsonJsonMapperTest.java b/src/test/java/com/retailsvc/http/internal/gson/GsonJsonMapperTest.java index 6d39e86..8fd1e28 100644 --- a/src/test/java/com/retailsvc/http/internal/gson/GsonJsonMapperTest.java +++ b/src/test/java/com/retailsvc/http/internal/gson/GsonJsonMapperTest.java @@ -98,6 +98,36 @@ void writesLocalTimeAsIso8601() { .isEqualTo("{\"t\":\"10:00\"}"); } + @Test + void readAsDeserialisesPojo() { + Item item = mapper.readAs(bytes("{\"id\":\"x-1\",\"qty\":7}"), "application/json", Item.class); + + assertThat(item.id).isEqualTo("x-1"); + assertThat(item.qty).isEqualTo(7); + } + + @Test + void readAsRoundTripsJsr310Fields() { + WithDates value = + mapper.readAs( + bytes("{\"ts\":\"2026-05-13T10:00:00Z\",\"day\":\"2026-05-13\"}"), + "application/json", + WithDates.class); + + assertThat(value.ts).isEqualTo(Instant.parse("2026-05-13T10:00:00Z")); + assertThat(value.day).isEqualTo(LocalDate.of(2026, 5, 13)); + } + + static final class Item { + String id; + int qty; + } + + static final class WithDates { + Instant ts; + LocalDate day; + } + private static byte[] bytes(String s) { return s.getBytes(StandardCharsets.UTF_8); } From 8464ff2cd9715b6fa3f921a982f6342af7b32c8d Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 14 May 2026 20:13:01 +0200 Subject: [PATCH 44/50] feat: Add Builder.jsonMapper(TypeMapper) shortcut bodyMapper("application/json", mapper) is the 95% case; jsonMapper(mapper) removes the repeated media-type literal. Generic bodyMapper(mediaType, mapper) stays for text/csv, application/xml, etc. --- README.md | 4 +- .../com/retailsvc/http/OpenApiServer.java | 4 ++ .../http/TypeMapperRegistrationTest.java | 42 +++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 08d1ed8..82c0ccf 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ It is designed to be simple to use while providing the essential features needed - For `application/json` request/response bodies, either: - Gson on the classpath — auto-registered via the built-in `GsonJsonMapper` (integer-preserving, JSR-310 written as ISO-8601), or - Jackson via the built-in `JacksonJsonTypeMapper(ObjectMapper)` adapter (caller supplies a configured `ObjectMapper`), or - - any other `TypeMapper` you register via `Builder.bodyMapper("application/json", mapper)`. + - any other `TypeMapper` you register via `Builder.jsonMapper(mapper)` (shortcut for `bodyMapper("application/json", mapper)`). - Built-in mappers for `application/x-www-form-urlencoded` and `text/plain` need no configuration. Any other media type (`application/xml`, `application/cbor`, etc.) requires registering its own `TypeMapper`. @@ -131,7 +131,7 @@ ObjectMapper objectMapper = new ObjectMapper() var server = OpenApiServer.builder() .spec(spec) - .bodyMapper("application/json", new JacksonJsonTypeMapper(objectMapper)) + .jsonMapper(new JacksonJsonTypeMapper(objectMapper)) .handlers(handlers) .build(); ``` diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index 246d800..8acd93b 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -145,6 +145,10 @@ public Builder bodyMapper(String mediaType, TypeMapper mapper) { return this; } + public Builder jsonMapper(TypeMapper mapper) { + return bodyMapper("application/json", mapper); + } + public Builder handlers(Map handlers) { this.handlers = handlers; return this; diff --git a/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java b/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java index 1e31d17..117d07f 100644 --- a/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java +++ b/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java @@ -88,6 +88,48 @@ public byte[] writeTo(Object v) { assertThat(readFromCalled).isTrue(); } + @Test + void jsonMapperShortcutRegistersUnderApplicationJson() throws Exception { + AtomicBoolean readFromCalled = new AtomicBoolean(); + TypeMapper marker = + new TypeMapper() { + @Override + public Object readFrom(byte[] b, String h) { + readFromCalled.set(true); + return Map.of("aList", List.of("x"), "feelingGood", true); + } + + @Override + public byte[] writeTo(Object v) { + return new byte[0]; + } + }; + RequestHandler echo = req -> Response.status(200); + server = + OpenApiServer.builder() + .spec(spec) + .jsonMapper(marker) + .handlers(Map.of("get-data", echo, "post-data", echo)) + .port(0) + .build(); + HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder() + .uri(URI.create("http://localhost:%d/api/v1/data".formatted(server.listenPort()))) + .header("Content-Type", "application/json") + .POST(BodyPublishers.ofString("{\"anything\":\"goes\"}")) + .build(), + ofString()); + + assertThat(readFromCalled).isTrue(); + } + + @Test + void jsonMapperRejectsNullMapper() { + OpenApiServer.Builder b = OpenApiServer.builder(); + assertThatThrownBy(() -> b.jsonMapper(null)).isInstanceOf(NullPointerException.class); + } + @Test void bodyMapperRejectsNullArgs() { OpenApiServer.Builder b = OpenApiServer.builder(); From 337471cdb7519fcef0398fa095f8108701564b3c Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 14 May 2026 20:13:44 +0200 Subject: [PATCH 45/50] feat!: Request.queryParam/header return Optional, treat blank as absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every list handler was writing: int limit = Optional.ofNullable(req.queryParam("limit")) .filter(v -> !v.isBlank()) .map(Integer::parseInt) .orElse(DEFAULT_LIMIT); The blank-vs-absent question is the kind of HTTP-API trap that's better solved once in the library than copied across every consumer. Returning Optional with blank already filtered shrinks the call site to: int limit = req.queryParam("limit").map(Integer::parseInt).orElse(DEFAULT_LIMIT); Same treatment for header(name). pathParam(name) is unchanged (still nullable) — path parameters come from the route template, not from the client, so blank is genuinely a different case. --- README.md | 16 ++++------ src/main/java/com/retailsvc/http/Request.java | 28 ++++++++++++---- .../java/com/retailsvc/http/RequestTest.java | 32 ++++++++++++++++--- 3 files changed, 56 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 82c0ccf..249b9d7 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,9 @@ public class PostDataHandler implements RequestHandler { // TypedTypeMapper). MyDto dto = request.asPojo(MyDto.class); // Path parameters, query parameters, and headers are also available. - String id = request.pathParam("id"); - String filter = request.queryParam("filter"); - String corr = request.header("correlation-id"); + String id = request.pathParam("id"); // null if absent + Optional filter = request.queryParam("filter"); // empty if absent or blank + Optional corr = request.header("correlation-id"); return Response.ok(dto); } @@ -182,7 +182,7 @@ OpenApiServer.builder() .handlers(handlers) .interceptor((request, next) -> { // Resolve once per request; bind to a ScopedValue for the rest of the chain. - String tenant = request.header("X-Tenant-Id"); + String tenant = request.header("X-Tenant-Id").orElse("public"); return ScopedValue.where(TENANT, tenant).call(next::proceed); }) .interceptor((request, next) -> { @@ -213,8 +213,7 @@ OpenApiServer.builder() // 1. Resolve once per request and bind to ScopedValues. .interceptor((request, next) -> { String correlationId = - Optional.ofNullable(request.header("X-Correlation-Id")) - .orElseGet(() -> UUID.randomUUID().toString()); + request.header("X-Correlation-Id").orElseGet(() -> UUID.randomUUID().toString()); String tenantId = resolveTenant(request); return ScopedValue.where(CORRELATION_ID, correlationId) .where(TENANT_ID, tenantId) @@ -282,10 +281,9 @@ public final class App { .handlers(Map.of("get-promotion", getPromotion)) // Bind tenant + correlation id once per request. .interceptor((req, next) -> { - String tenant = req.header("X-Tenant-Id"); + String tenant = req.header("X-Tenant-Id").orElse("public"); String correlationId = - Optional.ofNullable(req.header("X-Correlation-Id")) - .orElseGet(() -> UUID.randomUUID().toString()); + req.header("X-Correlation-Id").orElseGet(() -> UUID.randomUUID().toString()); return ScopedValue.where(TENANT, tenant) .where(CORRELATION_ID, correlationId) .call(next::proceed); diff --git a/src/main/java/com/retailsvc/http/Request.java b/src/main/java/com/retailsvc/http/Request.java index c7057a7..57db186 100644 --- a/src/main/java/com/retailsvc/http/Request.java +++ b/src/main/java/com/retailsvc/http/Request.java @@ -6,6 +6,7 @@ import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; +import java.util.Optional; /** * Read-only per-request handle passed to {@link RequestHandler}. Carries the parsed body, path @@ -70,12 +71,13 @@ public T asPojo(Class type) { if (parsed != null && type.isInstance(parsed)) { return type.cast(parsed); } + String contentType = exchange.getRequestHeaders().getFirst(CONTENT_TYPE); if (bodyMapper instanceof TypedTypeMapper typed) { - return typed.readAs(body, header(CONTENT_TYPE), type); + return typed.readAs(body, contentType, type); } throw new IllegalStateException( "body mapper for " - + header(CONTENT_TYPE) + + contentType + " does not support typed conversion; the mapper must implement TypedTypeMapper"); } @@ -92,8 +94,14 @@ public String pathParam(String name) { return pathParameters.get(name); } - public String header(String name) { - return exchange.getRequestHeaders().getFirst(name); + /** + * First value of the request header {@code name}, or {@link Optional#empty()} if absent or blank. + * Blank values are treated as missing so callers can write {@code req.header("X").map(...)} + * without the extra {@code filter(v -> !v.isBlank())} step. + */ + public Optional header(String name) { + String raw = exchange.getRequestHeaders().getFirst(name); + return raw == null || raw.isBlank() ? Optional.empty() : Optional.of(raw); } /** @@ -115,9 +123,15 @@ public Map queryParams() { return queryParamCache; } - /** First decoded value for {@code name}, or {@code null} if absent. */ - public String queryParam(String name) { - return queryParams().get(name); + /** + * First decoded value for query parameter {@code name}, or {@link Optional#empty()} if absent or + * blank. Blank values are treated as missing so callers can write {@code + * req.queryParam("limit").map(Integer::parseInt).orElse(DEFAULT)} without the extra {@code + * filter(v -> !v.isBlank())} step. + */ + public Optional queryParam(String name) { + String raw = queryParams().get(name); + return raw == null || raw.isBlank() ? Optional.empty() : Optional.of(raw); } private static Map parseQuery(String query) { diff --git a/src/test/java/com/retailsvc/http/RequestTest.java b/src/test/java/com/retailsvc/http/RequestTest.java index 2d6e859..c782a52 100644 --- a/src/test/java/com/retailsvc/http/RequestTest.java +++ b/src/test/java/com/retailsvc/http/RequestTest.java @@ -130,9 +130,9 @@ void exposesQueryParams() { Request req = new Request(exchange, new byte[0], null, null, "op", Map.of()); assertThat(req.rawQuery()).isEqualTo("name=Alice%20Smith&active=true&active=false"); - assertThat(req.queryParam("name")).isEqualTo("Alice Smith"); - assertThat(req.queryParam("active")).isEqualTo("true"); - assertThat(req.queryParam("missing")).isNull(); + assertThat(req.queryParam("name")).contains("Alice Smith"); + assertThat(req.queryParam("active")).contains("true"); + assertThat(req.queryParam("missing")).isEmpty(); assertThat(req.queryParams()) .containsEntry("name", "Alice Smith") .containsEntry("active", "true"); @@ -146,6 +146,30 @@ void queryParamsEmptyWhenNoQuery() { assertThat(req.rawQuery()).isNull(); assertThat(req.queryParams()).isEmpty(); - assertThat(req.queryParam("anything")).isNull(); + assertThat(req.queryParam("anything")).isEmpty(); + } + + @Test + void queryParamBlankIsTreatedAsAbsent() { + HttpExchange exchange = mock(HttpExchange.class); + when(exchange.getRequestURI()).thenReturn(URI.create("http://h/x?limit=&offset=%20")); + Request req = new Request(exchange, new byte[0], null, null, "op", Map.of()); + + assertThat(req.queryParam("limit")).isEmpty(); + assertThat(req.queryParam("offset")).isEmpty(); + } + + @Test + void headerReturnsOptionalAndBlankIsAbsent() { + HttpExchange exchange = mock(HttpExchange.class); + com.sun.net.httpserver.Headers h = new com.sun.net.httpserver.Headers(); + h.add("X-Trace", "abc"); + h.add("X-Empty", " "); + when(exchange.getRequestHeaders()).thenReturn(h); + Request req = new Request(exchange, new byte[0], null, null, "op", Map.of()); + + assertThat(req.header("X-Trace")).contains("abc"); + assertThat(req.header("X-Empty")).isEmpty(); + assertThat(req.header("Missing")).isEmpty(); } } From 8961a41344d2baa708db2b6a42f1d436dce40157 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 14 May 2026 20:14:06 +0200 Subject: [PATCH 46/50] refactor!: Rename Builder.addHandler to extraRoute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads more honestly. "handlers" and "addHandler" sounded like the same concept but they aren't — handlers(Map) is operationId-keyed and validated against the OpenAPI spec; the other is path-keyed and bypasses validation. "extraRoute" makes that distinction visible at the call site: .handlers(handlerMap) // OpenAPI operations .extraRoute("/alive", Handlers.aliveHandler()) // side route --- README.md | 4 ++-- src/main/java/com/retailsvc/http/OpenApiServer.java | 10 ++++++++-- src/test/java/com/retailsvc/http/ExtraHandlersIT.java | 6 +++--- .../com/retailsvc/http/OpenApiServerBuilderTest.java | 6 +++--- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 249b9d7..e1422bf 100644 --- a/README.md +++ b/README.md @@ -364,8 +364,8 @@ to OpenAPI parameter / body validation. var server = OpenApiServer.builder() .spec(spec) .handlers(handlers) - .addHandler("/alive", Handlers.aliveHandler()) - .addHandler("/schemas/v1/openapi.yaml", + .extraRoute("/alive", Handlers.aliveHandler()) + .extraRoute("/schemas/v1/openapi.yaml", Handlers.specHandler("/schemas/v1/openapi.yaml")) .build(); ``` diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index 8acd93b..ff133b9 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -197,11 +197,17 @@ public Builder shutdownTimeoutSeconds(int shutdownTimeoutSeconds) { return this; } - public Builder addHandler(String path, HttpHandler handler) { + /** + * Registers an extra HTTP route at {@code path} that bypasses OpenAPI validation and routing. + * Use for side concerns like {@code /alive}, {@code /health}, or serving the spec itself — + * anything that isn't an OpenAPI {@code operationId}. For OpenAPI-described operations use + * {@link #handlers(Map)}. + */ + public Builder extraRoute(String path, HttpHandler handler) { requireNonNull(path, "path must not be null"); requireNonNull(handler, "handler must not be null"); if (extras.containsKey(path)) { - throw new IllegalStateException("duplicate extra handler path: " + path); + throw new IllegalStateException("duplicate extra route path: " + path); } extras.put(path, handler); return this; diff --git a/src/test/java/com/retailsvc/http/ExtraHandlersIT.java b/src/test/java/com/retailsvc/http/ExtraHandlersIT.java index 0a5b6b4..59dc123 100644 --- a/src/test/java/com/retailsvc/http/ExtraHandlersIT.java +++ b/src/test/java/com/retailsvc/http/ExtraHandlersIT.java @@ -19,7 +19,7 @@ void aliveExtraReturns204AndBypassesValidation() throws Exception { .handlers(Map.of()) .exceptionHandler(defaultExceptionHandler()) .port(0) - .addHandler("/alive", Handlers.aliveHandler()) + .extraRoute("/alive", Handlers.aliveHandler()) .build(); var client = httpClient()) { @@ -43,7 +43,7 @@ void specHandlerServesClasspathResource() throws Exception { .handlers(Map.of()) .exceptionHandler(defaultExceptionHandler()) .port(0) - .addHandler("/openapi.yaml", Handlers.specHandler("/openapi.yaml")) + .extraRoute("/openapi.yaml", Handlers.specHandler("/openapi.yaml")) .build(); var client = httpClient()) { @@ -73,7 +73,7 @@ void extraHandlerExceptionFlowsThroughExceptionHandler() throws Exception { .handlers(Map.of()) .exceptionHandler(defaultExceptionHandler()) .port(0) - .addHandler("/boom", boom) + .extraRoute("/boom", boom) .build(); var client = httpClient()) { diff --git a/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java b/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java index 0e61ba0..b522cf5 100644 --- a/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java +++ b/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java @@ -28,9 +28,9 @@ void buildsWithRequiredFieldsOnly() { void rejectsDuplicateExtraPathOnSecondAddHandler() { HttpHandler duplicate = Handlers.aliveHandler(); OpenApiServer.Builder b = - OpenApiServer.builder().spec(spec).handlers(emptyMap()).addHandler("/alive", duplicate); + OpenApiServer.builder().spec(spec).handlers(emptyMap()).extraRoute("/alive", duplicate); - assertThatThrownBy(() -> b.addHandler("/alive", duplicate)) + assertThatThrownBy(() -> b.extraRoute("/alive", duplicate)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("/alive"); } @@ -42,7 +42,7 @@ void rejectsExtraPathEqualToSpecBasePathAtBuildTime() { OpenApiServer.builder() .spec(spec) .handlers(emptyMap()) - .addHandler("/api", Handlers.aliveHandler()) + .extraRoute("/api", Handlers.aliveHandler()) .port(0); assertThatThrownBy(b::build) From 0840082efe79f44a9c7dfe22c0318170f7c2f0a6 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 14 May 2026 20:14:16 +0200 Subject: [PATCH 47/50] feat!: Drop Response.created(body, location) overload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Location had a dedicated parameter slot; every other header went through withHeader. A real footgun — callers reached for Response.created(body) and forgot the Location header. Forcing .withHeader("Location", uri) for symmetry removes the trap. --- README.md | 3 ++- src/main/java/com/retailsvc/http/Response.java | 13 ++++--------- src/test/java/com/retailsvc/http/ResponseTest.java | 4 ++-- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index e1422bf..91c4160 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,8 @@ Response.empty(); // 204 No Content, no body Response.status(200); // 200 OK, no body Response.ok(Map.of("id", "42")); // 200 OK, JSON body via TypeMapper Response.created(newResource); // 201 Created, JSON body -Response.created(newResource, "/things/42"); // 201 Created + Location header +Response.created(newResource) + .withHeader("Location", "/things/42"); // 201 Created + Location header Response.accepted(); // 202 Accepted, no body Response.accepted(Map.of("jobId", "job-42")); // 202 Accepted, JSON body Response.notFound(); // 404 Not Found, no body diff --git a/src/main/java/com/retailsvc/http/Response.java b/src/main/java/com/retailsvc/http/Response.java index 7355083..f3e35b3 100644 --- a/src/main/java/com/retailsvc/http/Response.java +++ b/src/main/java/com/retailsvc/http/Response.java @@ -55,17 +55,12 @@ public static Response ok(Object body) { return new Response(HTTP_OK, body, null, Map.of()); } - /** {@code 201 Created} with {@code body} serialised as JSON. */ - public static Response created(Object body) { - return new Response(HTTP_CREATED, body, null, Map.of()); - } - /** - * {@code 201 Created} with {@code body} as JSON and a {@code Location} header — the canonical - * shape for a POST that creates a new resource. + * {@code 201 Created} with {@code body} serialised as JSON. Add a {@code Location} header for the + * new resource via {@link #withHeader(String, String) withHeader("Location", uri)}. */ - public static Response created(Object body, String location) { - return new Response(HTTP_CREATED, body, null, Map.of("Location", location)); + public static Response created(Object body) { + return new Response(HTTP_CREATED, body, null, Map.of()); } /** {@code 202 Accepted} with no body. Use for fire-and-forget async work. */ diff --git a/src/test/java/com/retailsvc/http/ResponseTest.java b/src/test/java/com/retailsvc/http/ResponseTest.java index 12d7ff6..162c00f 100644 --- a/src/test/java/com/retailsvc/http/ResponseTest.java +++ b/src/test/java/com/retailsvc/http/ResponseTest.java @@ -40,8 +40,8 @@ void createdWithBody() { } @Test - void createdWithLocation() { - Response r = Response.created(Map.of("id", "x-1"), "/things/x-1"); + void createdWithLocationViaWithHeader() { + Response r = Response.created(Map.of("id", "x-1")).withHeader("Location", "/things/x-1"); assertThat(r.status()).isEqualTo(HTTP_CREATED); assertThat(r.headers()).containsEntry("Location", "/things/x-1"); From 791203c987d4479efc9f3324b1326f90b605cf48 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 18 May 2026 08:39:52 +0200 Subject: [PATCH 48/50] refactor!: Decouple Request from HttpExchange to enable swappable transports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Request now holds transport-neutral primitives: body bytes, parsed body, TypeMapper, operationId, path parameters, raw query string, and a Function header lookup. The JDK HttpServer adapter (RequestPreparationFilter) is the only place that touches HttpExchange. Why: keeps the door open to a Netty / Helidon Nima / Jetty backend later if JDK HttpServer's throughput becomes the bottleneck. The handler-facing API (Request, Response, RequestHandler, RequestInterceptor, ResponseDecorator, TypeMapper) is now genuinely transport-neutral — a future adapter would live in com.retailsvc.http.internal and leave handler code untouched. README's 'Performance and caveats' section documents the rationale. --- README.md | 2 +- src/main/java/com/retailsvc/http/Request.java | 43 +++++--- .../internal/RequestPreparationFilter.java | 6 +- .../java/com/retailsvc/http/RequestTest.java | 99 +++++++++++-------- .../http/internal/DispatchHandlerTest.java | 2 +- 5 files changed, 98 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 91c4160..f1e0c80 100644 --- a/README.md +++ b/README.md @@ -433,7 +433,7 @@ The library wraps the JDK's bundled `com.sun.net.httpserver.HttpServer` and uses A few things to know: - **Single-process model.** No horizontal scaling primitives are bundled; run multiple instances behind a load balancer for production scale. -- **JDK HttpServer is the throughput ceiling.** It's documented as a low-throughput / dev-test server. If you need to go materially above the rates above, deploy the same filter/validator/router stack on Jetty, Helidon Níma, or Netty — the spec and validation code is server-agnostic. +- **JDK HttpServer is the throughput ceiling.** It's documented as a low-throughput / dev-test server. If you need to go materially above the rates above, the handler-facing API (`Request`, `Response`, `RequestHandler`, `RequestInterceptor`, `ResponseDecorator`, `TypeMapper`) is transport-neutral by design — `Request` is built from primitives (body bytes, raw query string, path parameters, a header lookup function), not a JDK `HttpExchange`. A future enhancement could plug in a higher-throughput backend (Jetty, Helidon Níma, Netty) by writing a new adapter behind `com.retailsvc.http.internal` while leaving handlers untouched. - **Per-request state uses `ScopedValue`** (Java 25, JEP 506). This matters if a handler offloads work to an executor that's not a `StructuredTaskScope`-managed child thread: the `ScopedValue` is not visible there, so the handler must capture the values it needs (e.g. `byte[] body = request.bytes();`) before submitting. - **Empty responses use `Response.empty()` (204) or `Response.status(code)` for other no-body statuses.** The renderer sends `responseLength = -1` (`Content-Length: 0`, no body) for any `Response` with `body() == null`, regardless of status code. Passing `0` to the JDK directly produces a chunked response with zero chunks, which is technically non-conformant — `Response` factories handle this for you. diff --git a/src/main/java/com/retailsvc/http/Request.java b/src/main/java/com/retailsvc/http/Request.java index 57db186..60254ec 100644 --- a/src/main/java/com/retailsvc/http/Request.java +++ b/src/main/java/com/retailsvc/http/Request.java @@ -1,43 +1,64 @@ package com.retailsvc.http; -import com.sun.net.httpserver.HttpExchange; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.function.Function; /** * Read-only per-request handle passed to {@link RequestHandler}. Carries the parsed body, path - * parameters, query parameters, headers, and operation ID. Handlers consume a {@code Request} and - * return a {@link Response}. + * parameters, query parameters, headers, and operation ID. + * + *

{@code Request} is transport-neutral: it holds the body bytes, the raw query string, the path + * parameter map, and a header lookup function. The transport adapter (today the built-in JDK {@code + * HttpServer}, tomorrow potentially Netty or another backend) is responsible for extracting those + * primitives from its own request representation. Handlers consume a {@code Request} and return a + * {@link Response}. */ public final class Request { private static final String CONTENT_TYPE = "Content-Type"; - private final HttpExchange exchange; private final byte[] body; private final Object parsed; private final TypeMapper bodyMapper; private final String operationId; private final Map pathParameters; + private final String rawQuery; + private final Function headerLookup; private Map queryParamCache; + /** + * Builds a {@code Request} from transport-neutral primitives. Adapters call this; handlers + * receive the constructed instance. + * + * @param body raw request body bytes; never {@code null}, may be empty + * @param parsed loose structural view of the body (Map / List / boxed primitive), or {@code null} + * @param bodyMapper {@link TypeMapper} that produced {@code parsed}, used for typed conversion; + * may be {@code null} if there is no body + * @param operationId the OpenAPI {@code operationId} the request was routed to + * @param pathParameters path variables extracted by the router + * @param rawQuery raw (percent-encoded) query string, or {@code null} if absent + * @param headerLookup first-value, case-insensitive header lookup; returns {@code null} if absent + */ public Request( - HttpExchange exchange, byte[] body, Object parsed, TypeMapper bodyMapper, String operationId, - Map pathParameters) { - this.exchange = exchange; + Map pathParameters, + String rawQuery, + Function headerLookup) { this.body = body; this.parsed = parsed; this.bodyMapper = bodyMapper; this.operationId = operationId; this.pathParameters = pathParameters; + this.rawQuery = rawQuery; + this.headerLookup = headerLookup; } public byte[] bytes() { @@ -71,7 +92,7 @@ public T asPojo(Class type) { if (parsed != null && type.isInstance(parsed)) { return type.cast(parsed); } - String contentType = exchange.getRequestHeaders().getFirst(CONTENT_TYPE); + String contentType = headerLookup.apply(CONTENT_TYPE); if (bodyMapper instanceof TypedTypeMapper typed) { return typed.readAs(body, contentType, type); } @@ -100,7 +121,7 @@ public String pathParam(String name) { * without the extra {@code filter(v -> !v.isBlank())} step. */ public Optional header(String name) { - String raw = exchange.getRequestHeaders().getFirst(name); + String raw = headerLookup.apply(name); return raw == null || raw.isBlank() ? Optional.empty() : Optional.of(raw); } @@ -109,7 +130,7 @@ public Optional header(String name) { * query component. */ public String rawQuery() { - return exchange.getRequestURI().getRawQuery(); + return rawQuery; } /** @@ -118,7 +139,7 @@ public String rawQuery() { */ public Map queryParams() { if (queryParamCache == null) { - queryParamCache = parseQuery(rawQuery()); + queryParamCache = parseQuery(rawQuery); } return queryParamCache; } diff --git a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java index f0e3443..7d528a6 100644 --- a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java +++ b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java @@ -64,14 +64,16 @@ public void doFilter(HttpExchange exchange, Chain chain) throws IOException { validateParameters(exchange, op, match.pathParameters()); ParsedBody parsedBody = validateAndParseBody(exchange, op, body); + var headers = exchange.getRequestHeaders(); Request request = new Request( - exchange, body, parsedBody.value(), parsedBody.mapper(), op.operationId(), - match.pathParameters()); + match.pathParameters(), + exchange.getRequestURI().getRawQuery(), + headers::getFirst); try { ScopedValue.where(DispatchHandler.CURRENT, request) diff --git a/src/test/java/com/retailsvc/http/RequestTest.java b/src/test/java/com/retailsvc/http/RequestTest.java index c782a52..bd3767b 100644 --- a/src/test/java/com/retailsvc/http/RequestTest.java +++ b/src/test/java/com/retailsvc/http/RequestTest.java @@ -2,27 +2,38 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; import com.fasterxml.jackson.databind.ObjectMapper; import com.retailsvc.http.internal.DispatchHandler; -import com.sun.net.httpserver.Headers; -import com.sun.net.httpserver.HttpExchange; -import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; import org.junit.jupiter.api.Test; class RequestTest { + private static final Function NO_HEADERS = name -> null; + + private static Function headers(String... pairs) { + Map map = new java.util.HashMap<>(); + for (int i = 0; i < pairs.length; i += 2) { + map.put(pairs[i].toLowerCase(), pairs[i + 1]); + } + return name -> map.get(name.toLowerCase()); + } + @Test void readsBoundContext() throws Exception { - HttpExchange exchange = mock(HttpExchange.class); Request req = new Request( - exchange, new byte[] {1, 2, 3}, Map.of("k", "v"), null, "get-x", Map.of("id", "42")); + new byte[] {1, 2, 3}, + Map.of("k", "v"), + null, + "get-x", + Map.of("id", "42"), + null, + NO_HEADERS); AtomicReference seenBytes = new AtomicReference<>(); AtomicReference seenParsed = new AtomicReference<>(); @@ -48,14 +59,17 @@ void readsBoundContext() throws Exception { @Test void asPojoDeserialisesViaTypedMapper() { - HttpExchange exchange = mock(HttpExchange.class); - Headers headers = new Headers(); - headers.add("Content-Type", "application/json"); - when(exchange.getRequestHeaders()).thenReturn(headers); JacksonJsonTypeMapper mapper = new JacksonJsonTypeMapper(new ObjectMapper()); byte[] body = "{\"id\":\"x-1\",\"qty\":7}".getBytes(StandardCharsets.UTF_8); Request req = - new Request(exchange, body, Map.of("id", "x-1", "qty", 7), mapper, "op", Map.of()); + new Request( + body, + Map.of("id", "x-1", "qty", 7), + mapper, + "op", + Map.of(), + null, + headers("Content-Type", "application/json")); Item item = req.asPojo(Item.class); @@ -65,9 +79,9 @@ void asPojoDeserialisesViaTypedMapper() { @Test void asPojoFastPathWhenParsedAlreadyMatchesType() { - HttpExchange exchange = mock(HttpExchange.class); Map alreadyParsed = Map.of("k", "v"); - Request req = new Request(exchange, "x".getBytes(), alreadyParsed, null, "op", Map.of()); + Request req = + new Request("x".getBytes(), alreadyParsed, null, "op", Map.of(), null, NO_HEADERS); Map result = req.asPojo(Map.class); assertThat(result).isSameAs(alreadyParsed); @@ -75,8 +89,7 @@ void asPojoFastPathWhenParsedAlreadyMatchesType() { @Test void asPojoThrowsWhenBodyEmpty() { - HttpExchange exchange = mock(HttpExchange.class); - Request req = new Request(exchange, new byte[0], null, null, "op", Map.of()); + Request req = new Request(new byte[0], null, null, "op", Map.of(), null, NO_HEADERS); assertThatThrownBy(() -> req.asPojo(Item.class)) .isInstanceOf(IllegalStateException.class) @@ -85,10 +98,6 @@ void asPojoThrowsWhenBodyEmpty() { @Test void asPojoThrowsWhenMapperNotTyped() { - HttpExchange exchange = mock(HttpExchange.class); - Headers headers = new Headers(); - headers.add("Content-Type", "text/plain"); - when(exchange.getRequestHeaders()).thenReturn(headers); TypeMapper plain = new TypeMapper() { @Override @@ -101,7 +110,15 @@ public byte[] writeTo(Object v) { return v.toString().getBytes(StandardCharsets.UTF_8); } }; - Request req = new Request(exchange, "hello".getBytes(), "hello", plain, "op", Map.of()); + Request req = + new Request( + "hello".getBytes(), + "hello", + plain, + "op", + Map.of(), + null, + headers("Content-Type", "text/plain")); assertThatThrownBy(() -> req.asPojo(Item.class)) .isInstanceOf(IllegalStateException.class) @@ -115,8 +132,7 @@ static final class Item { @Test void pathParamReturnsValueOrNull() { - HttpExchange exchange = mock(HttpExchange.class); - Request req = new Request(exchange, new byte[0], null, null, "op", Map.of("id", "42")); + Request req = new Request(new byte[0], null, null, "op", Map.of("id", "42"), null, NO_HEADERS); assertThat(req.pathParam("id")).isEqualTo("42"); assertThat(req.pathParam("missing")).isNull(); @@ -124,10 +140,15 @@ void pathParamReturnsValueOrNull() { @Test void exposesQueryParams() { - HttpExchange exchange = mock(HttpExchange.class); - when(exchange.getRequestURI()) - .thenReturn(URI.create("http://h/x?name=Alice%20Smith&active=true&active=false")); - Request req = new Request(exchange, new byte[0], null, null, "op", Map.of()); + Request req = + new Request( + new byte[0], + null, + null, + "op", + Map.of(), + "name=Alice%20Smith&active=true&active=false", + NO_HEADERS); assertThat(req.rawQuery()).isEqualTo("name=Alice%20Smith&active=true&active=false"); assertThat(req.queryParam("name")).contains("Alice Smith"); @@ -140,9 +161,7 @@ void exposesQueryParams() { @Test void queryParamsEmptyWhenNoQuery() { - HttpExchange exchange = mock(HttpExchange.class); - when(exchange.getRequestURI()).thenReturn(URI.create("http://h/x")); - Request req = new Request(exchange, new byte[0], null, null, "op", Map.of()); + Request req = new Request(new byte[0], null, null, "op", Map.of(), null, NO_HEADERS); assertThat(req.rawQuery()).isNull(); assertThat(req.queryParams()).isEmpty(); @@ -151,9 +170,8 @@ void queryParamsEmptyWhenNoQuery() { @Test void queryParamBlankIsTreatedAsAbsent() { - HttpExchange exchange = mock(HttpExchange.class); - when(exchange.getRequestURI()).thenReturn(URI.create("http://h/x?limit=&offset=%20")); - Request req = new Request(exchange, new byte[0], null, null, "op", Map.of()); + Request req = + new Request(new byte[0], null, null, "op", Map.of(), "limit=&offset=%20", NO_HEADERS); assertThat(req.queryParam("limit")).isEmpty(); assertThat(req.queryParam("offset")).isEmpty(); @@ -161,12 +179,15 @@ void queryParamBlankIsTreatedAsAbsent() { @Test void headerReturnsOptionalAndBlankIsAbsent() { - HttpExchange exchange = mock(HttpExchange.class); - com.sun.net.httpserver.Headers h = new com.sun.net.httpserver.Headers(); - h.add("X-Trace", "abc"); - h.add("X-Empty", " "); - when(exchange.getRequestHeaders()).thenReturn(h); - Request req = new Request(exchange, new byte[0], null, null, "op", Map.of()); + Request req = + new Request( + new byte[0], + null, + null, + "op", + Map.of(), + null, + headers("X-Trace", "abc", "X-Empty", " ")); assertThat(req.header("X-Trace")).contains("abc"); assertThat(req.header("X-Empty")).isEmpty(); diff --git a/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java b/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java index 466068a..0447708 100644 --- a/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java +++ b/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java @@ -32,7 +32,7 @@ private static DispatchHandler dispatcher(Map handlers) private static void withRequest( HttpExchange exchange, String operationId, ScopedValue.CallableOp body) throws Exception { - Request req = new Request(exchange, new byte[0], null, null, operationId, Map.of()); + Request req = new Request(new byte[0], null, null, operationId, Map.of(), null, n -> null); ScopedValue.where(DispatchHandler.CURRENT, req).call(body); } From bb9bdad6904a46ebce38a24d099d382788bdd946 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 18 May 2026 08:50:20 +0200 Subject: [PATCH 49/50] refactor: Address Sonar findings on transport-neutral Request - OpenApiServer: bundle handlers/interceptors/decorators/exceptionHandler/ extras into a private HandlerConfig record, dropping the constructor from 9 params to 5 (under Sonar's brain-overload threshold of 7). - OpenApiServer.jsonMapper: use the existing JSON constant instead of duplicating the "application/json" literal. - Request: switch headerLookup from Function to the more specialised UnaryOperator. - DispatchHandlerTest.withRequest: drop the unused HttpExchange parameter (no longer needed now that Request is built from primitives). --- .../com/retailsvc/http/OpenApiServer.java | 40 ++++++++++--------- src/main/java/com/retailsvc/http/Request.java | 6 +-- .../java/com/retailsvc/http/RequestTest.java | 6 +-- .../http/internal/DispatchHandlerTest.java | 5 +-- 4 files changed, 28 insertions(+), 29 deletions(-) diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index ff133b9..ee61c0e 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -43,21 +43,26 @@ public class OpenApiServer implements AutoCloseable { private final HttpServer httpServer; private final int shutdownTimeoutSeconds; - OpenApiServer( - Spec spec, - Map bodyMappers, + /** Internal grouping of handler-related configuration to keep the constructor signature small. */ + record HandlerConfig( Map handlers, - List decorators, List interceptors, + List decorators, ExceptionHandler exceptionHandler, + Map extras) {} + + OpenApiServer( + Spec spec, + Map bodyMappers, + HandlerConfig handlerConfig, int port, - Map extras, int shutdownTimeoutSeconds) throws IOException { requireNonNull(spec, "Spec must not be null"); requireNonNull(bodyMappers, "bodyMappers must not be null"); - requireNonNull(handlers, "handlers must not be null"); + requireNonNull(handlerConfig.handlers(), "handlers must not be null"); + ExceptionHandler exceptionHandler = handlerConfig.exceptionHandler(); if (exceptionHandler == null) { LOG.warn("No ExceptionHandler set, using default"); exceptionHandler = Handlers.defaultExceptionHandler(); @@ -74,9 +79,13 @@ public class OpenApiServer implements AutoCloseable { ctx.getFilters().add(new ExceptionFilter(exceptionHandler)); ctx.getFilters().add(new RequestPreparationFilter(spec, router, validator, bodyMappers)); ctx.setHandler( - new DispatchHandler(handlers, interceptors, decorators, new ResponseRenderer(bodyMappers))); + new DispatchHandler( + handlerConfig.handlers(), + handlerConfig.interceptors(), + handlerConfig.decorators(), + new ResponseRenderer(bodyMappers))); - for (Map.Entry e : extras.entrySet()) { + for (Map.Entry e : handlerConfig.extras().entrySet()) { HttpContext extraCtx = httpServer.createContext(e.getKey()); extraCtx.getFilters().add(new ExceptionFilter(exceptionHandler)); extraCtx.setHandler(e.getValue()); @@ -146,7 +155,7 @@ public Builder bodyMapper(String mediaType, TypeMapper mapper) { } public Builder jsonMapper(TypeMapper mapper) { - return bodyMapper("application/json", mapper); + return bodyMapper(JSON, mapper); } public Builder handlers(Map handlers) { @@ -224,16 +233,9 @@ public OpenApiServer build() throws IOException { } } Map resolved = resolveBodyMappers(bodyMappers); - return new OpenApiServer( - spec, - resolved, - handlers, - decorators, - interceptors, - exceptionHandler, - port, - extras, - shutdownTimeoutSeconds); + HandlerConfig handlerConfig = + new HandlerConfig(handlers, interceptors, decorators, exceptionHandler, extras); + return new OpenApiServer(spec, resolved, handlerConfig, port, shutdownTimeoutSeconds); } private static Map resolveBodyMappers( diff --git a/src/main/java/com/retailsvc/http/Request.java b/src/main/java/com/retailsvc/http/Request.java index 60254ec..62b4aa6 100644 --- a/src/main/java/com/retailsvc/http/Request.java +++ b/src/main/java/com/retailsvc/http/Request.java @@ -6,7 +6,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.function.Function; +import java.util.function.UnaryOperator; /** * Read-only per-request handle passed to {@link RequestHandler}. Carries the parsed body, path @@ -28,7 +28,7 @@ public final class Request { private final String operationId; private final Map pathParameters; private final String rawQuery; - private final Function headerLookup; + private final UnaryOperator headerLookup; private Map queryParamCache; /** @@ -51,7 +51,7 @@ public Request( String operationId, Map pathParameters, String rawQuery, - Function headerLookup) { + UnaryOperator headerLookup) { this.body = body; this.parsed = parsed; this.bodyMapper = bodyMapper; diff --git a/src/test/java/com/retailsvc/http/RequestTest.java b/src/test/java/com/retailsvc/http/RequestTest.java index bd3767b..f3a2ba9 100644 --- a/src/test/java/com/retailsvc/http/RequestTest.java +++ b/src/test/java/com/retailsvc/http/RequestTest.java @@ -8,14 +8,14 @@ import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Function; +import java.util.function.UnaryOperator; import org.junit.jupiter.api.Test; class RequestTest { - private static final Function NO_HEADERS = name -> null; + private static final UnaryOperator NO_HEADERS = name -> null; - private static Function headers(String... pairs) { + private static UnaryOperator headers(String... pairs) { Map map = new java.util.HashMap<>(); for (int i = 0; i < pairs.length; i += 2) { map.put(pairs[i].toLowerCase(), pairs[i + 1]); diff --git a/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java b/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java index 0447708..9666dce 100644 --- a/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java +++ b/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java @@ -29,8 +29,7 @@ private static DispatchHandler dispatcher(Map handlers) return new DispatchHandler(handlers, List.of(), List.of(), new ResponseRenderer(Map.of())); } - private static void withRequest( - HttpExchange exchange, String operationId, ScopedValue.CallableOp body) + private static void withRequest(String operationId, ScopedValue.CallableOp body) throws Exception { Request req = new Request(new byte[0], null, null, operationId, Map.of(), null, n -> null); ScopedValue.where(DispatchHandler.CURRENT, req).call(body); @@ -47,7 +46,6 @@ void invokesRegisteredHandler() throws Exception { HttpExchange ex = stubExchange(); withRequest( - ex, "get-x", () -> { dispatcher(Map.of("get-x", handler)).handle(ex); @@ -65,7 +63,6 @@ void throwsWhenHandlerMissing() { assertThatThrownBy( () -> withRequest( - ex, "ghost", () -> { d.handle(ex); From db24306bb0eaf339402a63f27526e69acc068269 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 18 May 2026 08:51:42 +0200 Subject: [PATCH 50/50] feat: Add Request.contentType() convenience accessor Returns the Content-Type header as Optional (blank treated as absent, matching header(name)). The most frequently inspected header by handler code; saves the magic-string typo risk. --- src/main/java/com/retailsvc/http/Request.java | 8 +++++++ .../java/com/retailsvc/http/RequestTest.java | 22 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/main/java/com/retailsvc/http/Request.java b/src/main/java/com/retailsvc/http/Request.java index 62b4aa6..bedca16 100644 --- a/src/main/java/com/retailsvc/http/Request.java +++ b/src/main/java/com/retailsvc/http/Request.java @@ -102,6 +102,14 @@ public T asPojo(Class type) { + " does not support typed conversion; the mapper must implement TypedTypeMapper"); } + /** + * Value of the {@code Content-Type} request header, or {@link Optional#empty()} if absent or + * blank. Convenience for {@code header("Content-Type")} — the most frequently inspected header. + */ + public Optional contentType() { + return header(CONTENT_TYPE); + } + public String operationId() { return operationId; } diff --git a/src/test/java/com/retailsvc/http/RequestTest.java b/src/test/java/com/retailsvc/http/RequestTest.java index f3a2ba9..98afcb5 100644 --- a/src/test/java/com/retailsvc/http/RequestTest.java +++ b/src/test/java/com/retailsvc/http/RequestTest.java @@ -177,6 +177,28 @@ void queryParamBlankIsTreatedAsAbsent() { assertThat(req.queryParam("offset")).isEmpty(); } + @Test + void contentTypeShortcutsContentTypeHeader() { + Request req = + new Request( + new byte[0], + null, + null, + "op", + Map.of(), + null, + headers("Content-Type", "application/json")); + + assertThat(req.contentType()).contains("application/json"); + } + + @Test + void contentTypeEmptyWhenHeaderAbsent() { + Request req = new Request(new byte[0], null, null, "op", Map.of(), null, NO_HEADERS); + + assertThat(req.contentType()).isEmpty(); + } + @Test void headerReturnsOptionalAndBlankIsAbsent() { Request req =