diff --git a/README.md b/README.md index 94cd0d4..f1e0c80 100644 --- a/README.md +++ b/README.md @@ -19,73 +19,93 @@ 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 + - Jackson via the built-in `JacksonJsonTypeMapper(ObjectMapper)` adapter (caller supplies a configured `ObjectMapper`), or + - 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`. ### 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. Handlers are pure functions: they consume a `Request` and return a `Response`. The framework renders the response (status code, headers, body) for you. ``` java -public class GetDataHandler implements HttpHandler { +// 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, the loose Map view, or a typed POJO. +public class PostDataHandler implements RequestHandler { @Override - public void handle(HttpExchange exchange) throws IOException { - try (exchange) { - byte[] bytes = """ - { - "id": "some-id" - }""".getBytes(); + public Response handle(Request request) { + // Access the raw request body bytes. + byte[] body = request.bytes(); + // Loose structural view (Map / List / boxed primitives), produced by the registered TypeMapper. + Object parsed = request.parsed(); + // 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"); // null if absent + Optional filter = request.queryParam("filter"); // empty if absent or blank + Optional corr = request.header("correlation-id"); + + return Response.ok(dto); + } +} +``` - var responseHeaders = exchange.getResponseHeaders(); - responseHeaders.add("content-type", "application/json"); +### Building responses - exchange.sendResponseHeaders(HTTP_OK, bytes.length); +`Response` is an immutable record built via static factories. Pick the one that fits: - try (var os = exchange.getResponseBody()) { - os.write(bytes); - } - } - } -} +``` java +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) + .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 +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 + out -> out.write(largeBlob)); +Response.stream(200, length, "application/pdf", // sized streaming + out -> pipeFromBackend(out)); +``` -public class PostDataHandler implements HttpHandler { - @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); - } - } -} +Add or modify pieces non-destructively: + +``` java +return Response.ok(payload) + .withHeader("X-Tenant-Id", tenant) + .withContentType("application/vnd.example+json"); ``` -3. Initialize the server (using Gson in this example): +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 { 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); - - // Body parser. Returns a Map for objects, List for arrays. - JsonMapper mapper = body -> gson.fromJson(new String(body), Object.class); + // 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<>(); - 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(); @@ -93,20 +113,205 @@ public class YourServerLauncher { } ``` -### YAML specifications -For YAML, replace the JSON parsing line with SnakeYAML: +`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 + +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` 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): + ``` java -Map raw = new Yaml().load(Files.newInputStream(Path.of("openapi.yaml"))); +ObjectMapper objectMapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + +var server = OpenApiServer.builder() + .spec(spec) + .jsonMapper(new JacksonJsonTypeMapper(objectMapper)) + .handlers(handlers) + .build(); +``` + +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`. + +### 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); +} ``` -The rest is identical. + +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. + +### Response decorators + +`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((req, resp) -> resp.withHeader("X-Correlation-Id", CorrelationId.current())) + .responseDecorator((req, resp) -> resp.withHeader("X-Tenant-Id", TenantId.current())) + .build(); +``` + +### 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. 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; bind to a ScopedValue for the rest of the chain. + String tenant = request.header("X-Tenant-Id").orElse("public"); + 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(); +``` + +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 pure business logic. + +``` 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 = + 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((req, resp) -> resp + .withHeader("X-Correlation-Id", CORRELATION_ID.get()) + .withHeader("X-Tenant-Id", TENANT_ID.get())) + .build(); +``` + +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 Response handle(Request request) { + String id = request.pathParam("id"); + String tenant = TENANT_ID.get(); + return promotionService + .find(tenant, id) + .map(Response::ok) + .orElseGet(Response::notFound); + } +} +``` + +### 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 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.pathParam("id"); + return PromotionService.find(TENANT.get(), id) // uses bound tenant + .map(Response::ok) // 200 + JSON via Gson + .orElseGet(Response::notFound); // 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").orElse("public"); + String correlationId = + 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 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,10 +364,9 @@ 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", + .extraRoute("/alive", Handlers.aliveHandler()) + .extraRoute("/schemas/v1/openapi.yaml", Handlers.specHandler("/schemas/v1/openapi.yaml")) .build(); ``` @@ -189,7 +393,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 +405,16 @@ 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 +- 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 ## 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 @@ -234,8 +433,8 @@ 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. -- **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. +- **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. ## Known limitations or missing features 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..47eb38e --- /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 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'); + } +} +``` + +- [ ] **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 writeToIsUnsupported() { + 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 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)); + } +} +``` + +- [ ] **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 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); + } +} +``` + +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 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("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 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); } + }; + 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 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]; } + } +} +``` + +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 respondJsonWritesBodyAndContentType() 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 respondEmptyUses204Style() 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 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(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. 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..f612adc --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-type-mapper-request-handler-design.md @@ -0,0 +1,237 @@ +# 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. +- 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. + +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 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. + +JSR-310 handling on write: + +- 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` + +`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)`. 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. + +## 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 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. +- `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; 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. diff --git a/pom.xml b/pom.xml index a1db729..b69351b 100644 --- a/pom.xml +++ b/pom.xml @@ -38,6 +38,18 @@ + + com.fasterxml.jackson.core + jackson-databind + 2.21.3 + true + + + com.google.code.gson + gson + 2.14.0 + true + org.yaml snakeyaml @@ -57,12 +69,6 @@ 1.5.32 test - - com.google.code.gson - gson - 2.14.0 - test - org.assertj assertj-core 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..dd95abb --- /dev/null +++ b/src/main/java/com/retailsvc/http/JacksonJsonTypeMapper.java @@ -0,0 +1,60 @@ +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. + * + *

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

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 TypedTypeMapper { + + 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) { + return readAs(body, contentTypeHeader, Object.class); + } + + @Override + public T readAs(byte[] body, String contentTypeHeader, Class type) { + try { + return mapper.readValue(body, type); + } 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/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..ee61c0e 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -6,8 +6,11 @@ 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.ResponseRenderer; 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; @@ -15,7 +18,10 @@ 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; import org.slf4j.Logger; @@ -30,57 +36,33 @@ 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, + /** Internal grouping of handler-related configuration to keep the constructor signature small. */ + record HandlerConfig( + Map handlers, + List interceptors, + List decorators, ExceptionHandler exceptionHandler, - int port) - throws IOException { - this(spec, jsonMapper, handlers, exceptionHandler, port, Map.of(), 0); - } + Map extras) {} OpenApiServer( Spec spec, - JsonMapper jsonMapper, - Map handlers, - ExceptionHandler exceptionHandler, + Map bodyMappers, + HandlerConfig handlerConfig, int port, - Map extras, int shutdownTimeoutSeconds) throws IOException { requireNonNull(spec, "Spec must not be null"); - requireNonNull(jsonMapper, "JsonMapper must not be null"); - requireNonNull(handlers, "handlers must not be null"); + requireNonNull(bodyMappers, "bodyMappers 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(); @@ -95,10 +77,15 @@ 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.setHandler(new DispatchHandler(handlers)); + ctx.getFilters().add(new RequestPreparationFilter(spec, router, validator, bodyMappers)); + ctx.setHandler( + 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()); @@ -144,8 +131,10 @@ public static Builder builder() { public static final class Builder { private Spec spec; - private JsonMapper jsonMapper; - private Map handlers; + 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; @@ -158,16 +147,41 @@ 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(Locale.ROOT), mapper); return this; } - public Builder handlers(Map handlers) { + public Builder jsonMapper(TypeMapper mapper) { + return bodyMapper(JSON, mapper); + } + + public Builder handlers(Map handlers) { this.handlers = handlers; return this; } + /** + * 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")); + 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; @@ -192,11 +206,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; @@ -204,7 +224,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 +232,44 @@ public OpenApiServer build() throws IOException { "extra handler path " + path + " conflicts with spec basePath " + basePath); } } - return new OpenApiServer( - spec, jsonMapper, handlers, exceptionHandler, port, extras, shutdownTimeoutSeconds); + Map resolved = resolveBodyMappers(bodyMappers); + HandlerConfig handlerConfig = + new HandlerConfig(handlers, interceptors, decorators, exceptionHandler, extras); + return new OpenApiServer(spec, resolved, handlerConfig, port, 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/Request.java b/src/main/java/com/retailsvc/http/Request.java index 2df6ce9..bedca16 100644 --- a/src/main/java/com/retailsvc/http/Request.java +++ b/src/main/java/com/retailsvc/http/Request.java @@ -1,48 +1,184 @@ package com.retailsvc.http; -import com.retailsvc.http.internal.RequestContext; +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.UnaryOperator; /** - * Static accessors for per-request state populated by the request-preparation filter. + * Read-only per-request handle passed to {@link RequestHandler}. Carries the parsed body, path + * parameters, query parameters, headers, and operation ID. * - *

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. + *

{@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 { - /** Bound by {@code RequestPreparationFilter} for the duration of each request. */ - public static final ScopedValue CONTEXT = ScopedValue.newInstance(); + private static final String CONTENT_TYPE = "Content-Type"; + + 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 UnaryOperator 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( + byte[] body, + Object parsed, + TypeMapper bodyMapper, + String operationId, + Map pathParameters, + String rawQuery, + UnaryOperator 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() { + return body; + } + + /** + * Loose structural view of the body (typically a {@code Map} / {@code List} / boxed primitive). + */ + public Object parsed() { + return parsed; + } - private Request() {} + /** + * 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); + } + String contentType = headerLookup.apply(CONTENT_TYPE); + if (bodyMapper instanceof TypedTypeMapper typed) { + return typed.readAs(body, contentType, type); + } + throw new IllegalStateException( + "body mapper for " + + contentType + + " does not support typed conversion; the mapper must implement TypedTypeMapper"); + } /** - * 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. + * 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 static RequestContext current() { - return CONTEXT.get(); + public Optional contentType() { + return header(CONTENT_TYPE); } - public static byte[] bytes() { - return CONTEXT.get().body(); + public String operationId() { + return operationId; } - public static Object parsed() { - return CONTEXT.get().parsedBody(); + public Map pathParams() { + return pathParameters; } - public static String operationId() { - return CONTEXT.get().operationId(); + /** Value of the path parameter {@code name}, or {@code null} if absent. */ + public String pathParam(String name) { + return pathParameters.get(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 = headerLookup.apply(name); + return raw == null || raw.isBlank() ? Optional.empty() : Optional.of(raw); + } + + /** + * Raw (percent-encoded) query string from the request URI, or {@code null} if the URI has no + * query component. + */ + public String rawQuery() { + return rawQuery; + } + + /** + * 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 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); } - public static Map pathParams() { - return CONTEXT.get().pathParameters(); + 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; } } 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..43493d6 --- /dev/null +++ b/src/main/java/com/retailsvc/http/RequestHandler.java @@ -0,0 +1,16 @@ +package com.retailsvc.http; + +/** + * 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 { + Response handle(Request request); +} 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..c855fbb --- /dev/null +++ b/src/main/java/com/retailsvc/http/RequestInterceptor.java @@ -0,0 +1,21 @@ +package com.retailsvc.http; + +/** + * 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()} and return its result (or a transformed + * value). Exceptions propagate to the configured {@link ExceptionHandler}. + */ +@FunctionalInterface +public interface RequestInterceptor { + + 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 { + 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..f3e35b3 --- /dev/null +++ b/src/main/java/com/retailsvc/http/Response.java @@ -0,0 +1,154 @@ +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; +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(HTTP_NO_CONTENT, 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(HTTP_OK, body, null, Map.of()); + } + + /** + * {@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) { + return new Response(HTTP_CREATED, body, null, Map.of()); + } + + /** {@code 202 Accepted} with no body. Use for fire-and-forget async work. */ + public static Response accepted() { + return new Response(HTTP_ACCEPTED, 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(HTTP_ACCEPTED, body, null, Map.of()); + } + + /** {@code 404 Not Found} with no body. */ + public static Response notFound() { + return new Response(HTTP_NOT_FOUND, 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(HTTP_NOT_FOUND, body, null, Map.of()); + } + + /** {@code 501 Not Implemented} with no body. */ + public static Response notImplemented() { + return new Response(HTTP_NOT_IMPLEMENTED, 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()); + } + + // -- 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/ResponseDecorator.java b/src/main/java/com/retailsvc/http/ResponseDecorator.java new file mode 100644 index 0000000..5603a98 --- /dev/null +++ b/src/main/java/com/retailsvc/http/ResponseDecorator.java @@ -0,0 +1,15 @@ +package com.retailsvc.http; + +/** + * 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. + * + *

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 { + Response decorate(Request request, Response response); +} 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/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/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/DispatchHandler.java b/src/main/java/com/retailsvc/http/internal/DispatchHandler.java index 8f02e80..d1234e9 100644 --- a/src/main/java/com/retailsvc/http/internal/DispatchHandler.java +++ b/src/main/java/com/retailsvc/http/internal/DispatchHandler.java @@ -2,25 +2,54 @@ import com.retailsvc.http.MissingOperationHandlerException; 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; +import java.util.List; 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; + private final List interceptors; + private final List decorators; + private final ResponseRenderer renderer; + + public DispatchHandler( + 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 { - String opId = Request.operationId(); - HttpHandler h = handlers.get(opId); - if (h == null) { - throw new MissingOperationHandlerException(opId); + Request request = CURRENT.get(); + RequestHandler handler = handlers.get(request.operationId()); + if (handler == null) { + throw new MissingOperationHandlerException(request.operationId()); + } + Response response = invoke(0, request, handler); + for (ResponseDecorator decorator : decorators) { + response = decorator.decorate(request, response); + } + renderer.render(exchange, response); + } + + private Response invoke(int idx, Request request, RequestHandler handler) { + if (idx == interceptors.size()) { + return handler.handle(request); } - h.handle(exchange); + return interceptors.get(idx).around(request, () -> invoke(idx + 1, request, handler)); } } 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/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/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/RequestContext.java b/src/main/java/com/retailsvc/http/internal/RequestContext.java deleted file mode 100644 index 70b8ee2..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 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. - * - *

{@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/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java index 58b5b3a..7d528a6 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; @@ -23,19 +23,19 @@ 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; - 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 @@ -62,36 +62,33 @@ public void doFilter(HttpExchange exchange, Chain chain) throws IOException { Operation op = match.operation(); validateParameters(exchange, op, match.pathParameters()); - Object parsedBody = validateAndParseBody(exchange, op, body); - - RequestContext ctx = - new RequestContext(body, parsedBody, op.operationId(), match.pathParameters()); - - runWithRequestContext(ctx, () -> chain.doFilter(exchange)); - } + ParsedBody parsedBody = validateAndParseBody(exchange, op, body); + + var headers = exchange.getRequestHeaders(); + Request request = + new Request( + body, + parsedBody.value(), + parsedBody.mapper(), + op.operationId(), + match.pathParameters(), + exchange.getRequestURI().getRawQuery(), + headers::getFirst); - private static void runWithRequestContext(RequestContext ctx, IORunnable work) - throws IOException { try { - ScopedValue.where(Request.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("/")) { @@ -130,17 +127,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", "required", "request body is required", null)); + 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); @@ -148,17 +150,22 @@ 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_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) { + @SuppressWarnings("unchecked") + Map typed = (Map) map; + parsed = FormBodyCoercion.coerce(typed, mt.schema()); } - Object parsed = - switch (mediaType) { - case "application/x-www-form-urlencoded" -> - formParser.parseAndCoerce(body, header, mt.schema()); - case "text/plain" -> textParser.parse(body, header); - default -> jsonMapper.mapFrom(body); - }; validator.validate(parsed, mt.schema(), ""); - return parsed; + return new ParsedBody(parsed, mapper); } private static Map parseQuery(String query) { 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..30f61f5 --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/ResponseRenderer.java @@ -0,0 +1,86 @@ +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 static final String OCTET_STREAM = "application/octet-stream"; + + 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); + } else if (body instanceof BodyWriter writer) { + renderStream(exchange, headers, status, response.contentType(), writer); + } else { + renderBytes(exchange, headers, status, response.contentType(), body); + } + } + } + + 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); + } + } + + 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); + } +} 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/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..72101e4 --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java @@ -0,0 +1,159 @@ +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.JsonToken; +import com.google.gson.stream.JsonWriter; +import com.retailsvc.http.TypedTypeMapper; +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; +import java.util.function.Function; + +/** + * 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. + * + *

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 TypedTypeMapper { + + private final Gson gson; + + public GsonJsonMapper() { + this.gson = + new GsonBuilder() + .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(); + } + + @Override + public Object readFrom(byte[] body, String contentTypeHeader) { + JsonElement element = JsonParser.parseString(new String(body, StandardCharsets.UTF_8)); + 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); + } + + /** + * 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; + } + 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); + } + + /** 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 { + if (value == null) { + out.nullValue(); + } else { + out.value(toIso.apply(value)); + } + } + + @Override + 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/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/DecoratorAndInterceptorIT.java b/src/test/java/com/retailsvc/http/DecoratorAndInterceptorIT.java new file mode 100644 index 0000000..f022666 --- /dev/null +++ b/src/test/java/com/retailsvc/http/DecoratorAndInterceptorIT.java @@ -0,0 +1,113 @@ +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.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 { + + static final ScopedValue TENANT = ScopedValue.newInstance(); + + @Test + void responseDecoratorAddsHeadersOnEveryResponse() throws Exception { + RequestHandler ok = req -> Response.text(HTTP_OK, "ok"); + server = + OpenApiServer.builder() + .spec(spec) + .handlers(Map.of("get-data", ok, "post-data", ok)) + .responseDecorator((req, resp) -> resp.withHeader("X-Correlation-Id", "decorator-cid")) + .responseDecorator((req, resp) -> resp.withHeader("X-Op", req.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 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((req, resp) -> resp.withHeader("X-Op", "decorator-wins")) + .port(0) + .build(); + + var resp = call("/api/v1/data"); + + assertThat(resp.headers().firstValue("X-Op")).contains("decorator-wins"); + } + + @Test + void interceptorBindsScopedValueVisibleToHandler() throws Exception { + 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)) + .port(0) + .build(); + + assertThat(call("/api/v1/data").body()).isEqualTo("acme"); + } + + @Test + void interceptorsRunInRegistrationOrder() throws Exception { + List trace = new CopyOnWriteArrayList<>(); + RequestHandler ok = + req -> { + trace.add("handler"); + return Response.status(HTTP_OK); + }; + server = + OpenApiServer.builder() + .spec(spec) + .handlers(Map.of("get-data", ok, "post-data", ok)) + .interceptor( + (request, next) -> { + trace.add("outer-before"); + Response r = next.proceed(); + trace.add("outer-after"); + return r; + }) + .interceptor( + (request, next) -> { + trace.add("inner-before"); + Response r = next.proceed(); + trace.add("inner-after"); + return r; + }) + .port(0) + .build(); + + call("/api/v1/data"); + + assertThat(trace) + .containsExactly("outer-before", "inner-before", "handler", "inner-after", "outer-after"); + } + + private 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/ExtraHandlersIT.java b/src/test/java/com/retailsvc/http/ExtraHandlersIT.java index 9be1629..59dc123 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,11 +16,10 @@ void aliveExtraReturns204AndBypassesValidation() throws Exception { try (var s = OpenApiServer.builder() .spec(spec) - .jsonMapper(jsonMapper()) .handlers(Map.of()) .exceptionHandler(defaultExceptionHandler()) .port(0) - .addHandler("/alive", Handlers.aliveHandler()) + .extraRoute("/alive", Handlers.aliveHandler()) .build(); var client = httpClient()) { @@ -44,11 +40,10 @@ void specHandlerServesClasspathResource() throws Exception { try (var s = OpenApiServer.builder() .spec(spec) - .jsonMapper(jsonMapper()) .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()) { @@ -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,11 +70,10 @@ void extraHandlerExceptionFlowsThroughExceptionHandler() throws Exception { try (var s = OpenApiServer.builder() .spec(spec) - .jsonMapper(jsonMapper()) .handlers(Map.of()) .exceptionHandler(defaultExceptionHandler()) .port(0) - .addHandler("/boom", boom) + .extraRoute("/boom", boom) .build(); var client = httpClient()) { @@ -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/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/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/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/OpenApiServerBuilderTest.java b/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java index c9a2f3b..b522cf5 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,13 +28,9 @@ 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()).extraRoute("/alive", duplicate); - assertThatThrownBy(() -> b.addHandler("/alive", duplicate)) + assertThatThrownBy(() -> b.extraRoute("/alive", duplicate)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("/alive"); } @@ -52,9 +41,8 @@ void rejectsExtraPathEqualToSpecBasePathAtBuildTime() { OpenApiServer.Builder b = OpenApiServer.builder() .spec(spec) - .jsonMapper(jsonMapper) .handlers(emptyMap()) - .addHandler("/api", Handlers.aliveHandler()) + .extraRoute("/api", Handlers.aliveHandler()) .port(0); assertThatThrownBy(b::build) @@ -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/OpenApiServerIT.java b/src/test/java/com/retailsvc/http/OpenApiServerIT.java index 922bf7d..6b6e5e7 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 -> Response.status(200))); 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 -> Response.status(200))); 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 -> Response.status(200))); 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 -> Response.status(200))); 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 -> Response.status(200))); 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 -> Response.status(200))); var client = httpClient()) { var request = newRequest(server, path + "?n=42", "GET", noBody()); diff --git a/src/test/java/com/retailsvc/http/OpenApiServerTest.java b/src/test/java/com/retailsvc/http/OpenApiServerTest.java index 9c05976..3db7ec5 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,38 +29,28 @@ void shouldStartHttpServerWithValidConfiguration() { @Test void shouldThrowExceptionWhenSpecIsNull() { - Map handlers = emptyMap(); + OpenApiServer.Builder builder = OpenApiServer.builder().handlers(emptyMap()).port(0); - assertThatThrownBy(() -> new OpenApiServer(null, jsonMapper, handlers, onError)) + assertThatThrownBy(builder::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(); + OpenApiServer.Builder builder = OpenApiServer.builder().spec(testSpec()).port(0); - assertThatThrownBy(() -> new OpenApiServer(validSpec, jsonMapper, null, onError)) + assertThatThrownBy(builder::build) .isInstanceOf(NullPointerException.class) .hasMessageContaining("handlers must not be null"); } @Test void testExceptionIsThrownOnInvalidHttpPort() { - Spec validSpec = testSpec(); - Map handlers = emptyMap(); - assertThatThrownBy(() -> new OpenApiServer(validSpec, jsonMapper, handlers, onError, -1)) - .isInstanceOf(IllegalArgumentException.class); + OpenApiServer.Builder builder = + OpenApiServer.builder().spec(testSpec()).handlers(emptyMap()).port(-1); + + assertThatThrownBy(builder::build).isInstanceOf(IllegalArgumentException.class); } private Spec testSpec() { 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..ab32347 --- /dev/null +++ b/src/test/java/com/retailsvc/http/RequestResponseGatewayTest.java @@ -0,0 +1,99 @@ +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; +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 respondJsonWritesBodyAndContentType() throws Exception { + RequestHandler echo = req -> Response.ok(Map.of("op", req.operationId())); + 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("{\"aList\":[\"x\"],\"feelingGood\":true}")) + .build(), + ofString()); + 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 -> Response.status(HTTP_NO_CONTENT); + server = + OpenApiServer.builder() + .spec(spec) + .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/data".formatted(server.listenPort()))) + .GET() + .build(), + ofString()); + assertThat(resp.statusCode()).isEqualTo(HTTP_NO_CONTENT); + assertThat(resp.body()).isEmpty(); + } + + @Test + void respondStreamUsesChunkedEncoding() throws Exception { + RequestHandler streamer = + req -> + Response.stream( + HTTP_OK, + "text/plain", + out -> { + out.write("hello ".getBytes()); + out.write("world".getBytes()); + }); + server = + OpenApiServer.builder() + .spec(spec) + .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/data".formatted(server.listenPort()))) + .GET() + .build(), + ofString()); + 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 ab47779..98afcb5 100644 --- a/src/test/java/com/retailsvc/http/RequestTest.java +++ b/src/test/java/com/retailsvc/http/RequestTest.java @@ -1,33 +1,53 @@ package com.retailsvc.http; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.assertj.core.api.Assertions.assertThatThrownBy; -import com.retailsvc.http.internal.RequestContext; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.retailsvc.http.internal.DispatchHandler; +import java.nio.charset.StandardCharsets; import java.util.Map; -import java.util.NoSuchElementException; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.UnaryOperator; import org.junit.jupiter.api.Test; class RequestTest { + private static final UnaryOperator NO_HEADERS = name -> null; + + 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]); + } + return name -> map.get(name.toLowerCase()); + } + @Test void readsBoundContext() throws Exception { - RequestContext ctx = - new RequestContext(new byte[] {1, 2, 3}, Map.of("k", "v"), "get-x", Map.of("id", "42")); + Request req = + new Request( + 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<>(); AtomicReference seenOpId = new AtomicReference<>(); AtomicReference> seenPathParams = new AtomicReference<>(); - ScopedValue.where(Request.CONTEXT, ctx) + ScopedValue.where(DispatchHandler.CURRENT, req) .call( () -> { - seenBytes.set(Request.bytes()); - seenParsed.set(Request.parsed()); - seenOpId.set(Request.operationId()); - seenPathParams.set(Request.pathParams()); + Request r = DispatchHandler.CURRENT.get(); + seenBytes.set(r.bytes()); + seenParsed.set(r.parsed()); + seenOpId.set(r.operationId()); + seenPathParams.set(r.pathParams()); return null; }); @@ -38,7 +58,161 @@ void readsBoundContext() throws Exception { } @Test - void readingOutsideScopeThrows() { - assertThrows(NoSuchElementException.class, Request::bytes); + void asPojoDeserialisesViaTypedMapper() { + JacksonJsonTypeMapper mapper = new JacksonJsonTypeMapper(new ObjectMapper()); + byte[] body = "{\"id\":\"x-1\",\"qty\":7}".getBytes(StandardCharsets.UTF_8); + Request req = + 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); + + assertThat(item.id).isEqualTo("x-1"); + assertThat(item.qty).isEqualTo(7); + } + + @Test + void asPojoFastPathWhenParsedAlreadyMatchesType() { + Map alreadyParsed = Map.of("k", "v"); + Request req = + new Request("x".getBytes(), alreadyParsed, null, "op", Map.of(), null, NO_HEADERS); + + Map result = req.asPojo(Map.class); + assertThat(result).isSameAs(alreadyParsed); + } + + @Test + void asPojoThrowsWhenBodyEmpty() { + Request req = new Request(new byte[0], null, null, "op", Map.of(), null, NO_HEADERS); + + assertThatThrownBy(() -> req.asPojo(Item.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("no body"); + } + + @Test + void asPojoThrowsWhenMapperNotTyped() { + 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( + "hello".getBytes(), + "hello", + plain, + "op", + Map.of(), + null, + headers("Content-Type", "text/plain")); + + assertThatThrownBy(() -> req.asPojo(Item.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("TypedTypeMapper"); + } + + static final class Item { + public String id; + public int qty; + } + + @Test + void pathParamReturnsValueOrNull() { + 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(); + } + + @Test + void exposesQueryParams() { + 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"); + assertThat(req.queryParam("active")).contains("true"); + assertThat(req.queryParam("missing")).isEmpty(); + assertThat(req.queryParams()) + .containsEntry("name", "Alice Smith") + .containsEntry("active", "true"); + } + + @Test + void queryParamsEmptyWhenNoQuery() { + Request req = new Request(new byte[0], null, null, "op", Map.of(), null, NO_HEADERS); + + assertThat(req.rawQuery()).isNull(); + assertThat(req.queryParams()).isEmpty(); + assertThat(req.queryParam("anything")).isEmpty(); + } + + @Test + void queryParamBlankIsTreatedAsAbsent() { + 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(); + } + + @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 = + 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(); + assertThat(req.header("Missing")).isEmpty(); } } 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..162c00f --- /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 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"); + } + + @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(); + } +} diff --git a/src/test/java/com/retailsvc/http/ServerBaseTest.java b/src/test/java/com/retailsvc/http/ServerBaseTest.java index a0eb8cf..fa081e1 100644 --- a/src/test/java/com/retailsvc/http/ServerBaseTest.java +++ b/src/test/java/com/retailsvc/http/ServerBaseTest.java @@ -1,13 +1,11 @@ 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; 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; @@ -45,13 +43,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) { + 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..117d07f --- /dev/null +++ b/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java @@ -0,0 +1,155 @@ +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 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.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +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 { + RequestHandler echo = + req -> + Response.bytes( + 200, + gson.toJson(req.parsed()).getBytes(StandardCharsets.UTF_8), + "application/json"); + 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 { + 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 "ignored".getBytes(StandardCharsets.UTF_8); + } + }; + RequestHandler echo = req -> Response.status(200); + server = + OpenApiServer.builder() + .spec(spec) + .bodyMapper("application/json", 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 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(); + TypeMapper anyMapper = new GsonOnlyMapper(); + + assertThatThrownBy(() -> b.bodyMapper(null, anyMapper)) + .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/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'); + } +} diff --git a/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java b/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java index b34740d..9666dce 100644 --- a/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java +++ b/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java @@ -1,48 +1,68 @@ 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 com.sun.net.httpserver.HttpHandler; +import java.util.List; 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(Request.CONTEXT, ctx).call(body); + private static HttpExchange stubExchange() { + HttpExchange exchange = mock(HttpExchange.class); + 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(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); } @Test void invokesRegisteredHandler() throws Exception { - HttpHandler handler = mock(HttpHandler.class); - HttpExchange ex = mock(HttpExchange.class); + AtomicBoolean called = new AtomicBoolean(false); + RequestHandler handler = + req -> { + called.set(true); + return Response.status(HTTP_OK); + }; + HttpExchange ex = stubExchange(); - withOperationId( + withRequest( "get-x", () -> { - new DispatchHandler(Map.of("get-x", handler)).handle(ex); + dispatcher(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 void throwsWhenHandlerMissing() { - DispatchHandler d = new DispatchHandler(Map.of()); - HttpExchange ex = mock(HttpExchange.class); + DispatchHandler d = dispatcher(Map.of()); + HttpExchange ex = stubExchange(); assertThatThrownBy( () -> - withOperationId( + withRequest( "ghost", () -> { d.handle(ex); 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..2cb9f4f --- /dev/null +++ b/src/test/java/com/retailsvc/http/internal/FormTypeMapperTest.java @@ -0,0 +1,31 @@ +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() { + Map value = Map.of("k", "v"); + + assertThatThrownBy(() -> mapper.writeTo(value)) + .isInstanceOf(UnsupportedOperationException.class); + } +} diff --git a/src/test/java/com/retailsvc/http/internal/FormUrlEncodedParserTest.java b/src/test/java/com/retailsvc/http/internal/FormUrlEncodedParserTest.java index 5db5173..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; @@ -84,7 +85,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 +98,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))); } @@ -106,9 +109,10 @@ void coercionFailureThrowsValidationExceptionAtPropertyPointer() { IntegerSchema intSchema = anIntegerSchema(); ObjectSchema bodySchema = anObjectSchema(Map.of("age", intSchema)); - org.assertj.core.api.Assertions.assertThatThrownBy( + 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 +123,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 +134,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")); } 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]"); - } -} diff --git a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java index 429f98c..e07d43f 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 @@ -87,8 +100,9 @@ void successPathBindsRequestContextDuringChain() throws Exception { Filter.Chain chain = mock(Filter.Chain.class); Mockito.doAnswer( inv -> { - seenOpId.set(Request.operationId()); - seenPathParams.set(Request.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/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)); + } +} 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..8fd1e28 --- /dev/null +++ b/src/test/java/com/retailsvc/http/internal/gson/GsonJsonMapperTest.java @@ -0,0 +1,134 @@ +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) + .containsEntry("s", "hi") + .containsEntry("b", Boolean.TRUE) + .containsEntry("n", null) + .containsEntry("a", 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\"}"); + } + + @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); + } +} 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..4542877 --- /dev/null +++ b/src/test/java/com/retailsvc/http/spec/SpecFromPathTest.java @@ -0,0 +1,43 @@ +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 { + + @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(@TempDir Path tmp) throws Exception { + Path unknown = tmp.resolve("spec.txt"); + Files.writeString(unknown, "{}"); + + assertThatThrownBy(() -> Spec.fromPath(unknown)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Unrecognised OpenAPI spec extension"); + } +} diff --git a/src/test/java/com/retailsvc/http/start/EchoHandler.java b/src/test/java/com/retailsvc/http/start/EchoHandler.java index f02a767..fe78205 100644 --- a/src/test/java/com/retailsvc/http/start/EchoHandler.java +++ b/src/test/java/com/retailsvc/http/start/EchoHandler.java @@ -1,38 +1,27 @@ package com.retailsvc.http.start; +import static java.net.HttpURLConnection.HTTP_OK; + import com.retailsvc.http.Request; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; -import java.io.IOException; +import com.retailsvc.http.RequestHandler; +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 */ -public class EchoHandler implements HttpHandler { +/** 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(HttpExchange exchange) throws IOException { - byte[] bytes = Request.bytes(); - + 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); - - 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()); - } + 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 24249af..9572a00 100644 --- a/src/test/java/com/retailsvc/http/start/FormEchoHandler.java +++ b/src/test/java/com/retailsvc/http/start/FormEchoHandler.java @@ -3,22 +3,14 @@ import static java.net.HttpURLConnection.HTTP_OK; import com.retailsvc.http.Request; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; -import java.io.IOException; -import java.nio.charset.StandardCharsets; +import com.retailsvc.http.RequestHandler; +import com.retailsvc.http.Response; /** 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 = 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); - } + 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 fe4f13d..1e59642 100644 --- a/src/test/java/com/retailsvc/http/start/GetDataHandler.java +++ b/src/test/java/com/retailsvc/http/start/GetDataHandler.java @@ -2,36 +2,20 @@ import static java.net.HttpURLConnection.HTTP_OK; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; -import java.io.IOException; +import com.retailsvc.http.Request; +import com.retailsvc.http.RequestHandler; +import com.retailsvc.http.Response; +import java.util.Map; 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 Response handle(Request request) { 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); - } - } + 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 9633644..61dc9f0 100644 --- a/src/test/java/com/retailsvc/http/start/ParamHandler.java +++ b/src/test/java/com/retailsvc/http/start/ParamHandler.java @@ -2,24 +2,19 @@ import static java.net.HttpURLConnection.HTTP_OK; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; -import java.io.IOException; +import com.retailsvc.http.Request; +import com.retailsvc.http.RequestHandler; +import com.retailsvc.http.Response; 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 Response handle(Request request) { 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); - } + 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 4f2a1d6..479f055 100644 --- a/src/test/java/com/retailsvc/http/start/PostDataHandler.java +++ b/src/test/java/com/retailsvc/http/start/PostDataHandler.java @@ -1,38 +1,27 @@ package com.retailsvc.http.start; +import static java.net.HttpURLConnection.HTTP_OK; + import com.retailsvc.http.Request; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; -import java.io.IOException; +import com.retailsvc.http.RequestHandler; +import com.retailsvc.http.Response; 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 Response handle(Request request) { LOG.debug("POST /data"); - - byte[] bytes = Request.bytes(); - + 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); - - try (exchange; - var os = exchange.getResponseBody()) { - exchange.getResponseHeaders().add("Content-Type", "application/json"); - exchange.sendResponseHeaders(200, requestBody.length()); - - os.write(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 9d6c559..5ae48b9 100644 --- a/src/test/java/com/retailsvc/http/start/PostListObjectsHandler.java +++ b/src/test/java/com/retailsvc/http/start/PostListObjectsHandler.java @@ -1,37 +1,21 @@ package com.retailsvc.http.start; +import static java.net.HttpURLConnection.HTTP_OK; + import com.retailsvc.http.Request; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; -import java.io.IOException; +import com.retailsvc.http.RequestHandler; +import com.retailsvc.http.Response; 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 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); - - try (var os = exchange.getResponseBody(); - exchange) { - exchange.getResponseHeaders().add("Content-Type", "application/json"); - exchange.sendResponseHeaders(200, requestBody.getBytes().length); - os.write(requestBody.getBytes()); - } + return Response.bytes(HTTP_OK, request.bytes(), "application/json"); } } diff --git a/src/test/java/com/retailsvc/http/start/ServerLauncher.java b/src/test/java/com/retailsvc/http/start/ServerLauncher.java index 08c004e..614d5b8 100644 --- a/src/test/java/com/retailsvc/http/start/ServerLauncher.java +++ b/src/test/java/com/retailsvc/http/start/ServerLauncher.java @@ -1,12 +1,10 @@ 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.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; @@ -26,15 +24,13 @@ 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); } 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()); @@ -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); } } diff --git a/src/test/java/com/retailsvc/http/start/TextEchoHandler.java b/src/test/java/com/retailsvc/http/start/TextEchoHandler.java index 8c69826..af0a77f 100644 --- a/src/test/java/com/retailsvc/http/start/TextEchoHandler.java +++ b/src/test/java/com/retailsvc/http/start/TextEchoHandler.java @@ -3,22 +3,14 @@ import static java.net.HttpURLConnection.HTTP_OK; import com.retailsvc.http.Request; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; -import java.io.IOException; -import java.nio.charset.StandardCharsets; +import com.retailsvc.http.RequestHandler; +import com.retailsvc.http.Response; /** 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) 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); - } + public Response handle(Request request) { + return Response.text(HTTP_OK, (String) request.parsed()); } }