Skip to content

Commit 2e02e53

Browse files
committed
feat!: Handlers return Response value object; remove ResponseBuilder
Handlers now return an immutable Response record instead of mutating an HttpExchange via the ResponseBuilder fluent API. Response is a pure value: status + body + contentType + headers, with factories for common shapes (empty/status/ok/of/text/bytes/stream). ResponseDecorator becomes a (Request, Response) -> Response transform applied after the handler returns; interceptors and continuations return Response too so cross-cutting work composes cleanly. Removed: ResponseBuilder, DefaultResponseBuilder, Request.respond(int), the per-Request responseSent flag, and the IllegalStateException state machine - single-shot is enforced by the return type. Renderer (internal ResponseRenderer) handles byte[] / BodyWriter / mapper.writeTo dispatch, including null-body -> responseLength=-1.
1 parent 0761986 commit 2e02e53

27 files changed

Lines changed: 463 additions & 457 deletions

README.md

Lines changed: 70 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,15 @@ It is designed to be simple to use while providing the essential features needed
2626

2727
### Basic Usage
2828
1. Create an OpenAPI specification file named `openapi.json` in your project resources.
29-
2. Define your handlers using the `RequestHandler` functional interface:
29+
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.
3030
``` java
3131
// Inline lambda — returns JSON using the built-in Gson mapper.
32-
RequestHandler getDataHandler = req ->
33-
req.respond(200).json(Map.of("id", "some-id"));
32+
RequestHandler getDataHandler = req -> Response.ok(Map.of("id", "some-id"));
3433

3534
// Class form — reads raw bytes or the pre-parsed body object.
3635
public class PostDataHandler implements RequestHandler {
3736
@Override
38-
public void handle(Request request) throws IOException {
37+
public Response handle(Request request) {
3938
// Access the raw request body bytes.
4039
byte[] body = request.bytes();
4140
// Or get the already-parsed object (Map / List) produced by the registered TypeMapper.
@@ -45,11 +44,39 @@ public class PostDataHandler implements RequestHandler {
4544
String filter = request.queryParam("filter");
4645
String corr = request.header("correlation-id");
4746

48-
request.respond(200).json(parsed);
47+
return Response.ok(parsed);
4948
}
5049
}
5150
```
5251

52+
### Building responses
53+
54+
`Response` is an immutable record built via static factories. Pick the one that fits:
55+
56+
``` java
57+
Response.empty(); // 204 No Content, no body
58+
Response.status(404); // 404, no body
59+
Response.status(200); // 200 OK, no body
60+
Response.ok(Map.of("id", "42")); // 200 OK, JSON body via TypeMapper
61+
Response.of(201, newResource); // any status, JSON body
62+
Response.text(200, "hello"); // text/plain; UTF-8
63+
Response.bytes(200, pdf, "application/pdf"); // pre-serialised bytes
64+
Response.stream(200, "application/octet-stream", // chunked streaming
65+
out -> out.write(largeBlob));
66+
Response.stream(200, length, "application/pdf", // sized streaming
67+
out -> pipeFromBackend(out));
68+
```
69+
70+
Add or modify pieces non-destructively:
71+
72+
``` java
73+
return Response.ok(payload)
74+
.withHeader("X-Tenant-Id", tenant)
75+
.withContentType("application/vnd.example+json");
76+
```
77+
78+
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`).
79+
5380
3. Initialize the server:
5481
``` java
5582
public class YourServerLauncher {
@@ -122,60 +149,46 @@ User-supplied mappers take precedence over built-in defaults, so you can overrid
122149

123150
### Response decorators
124151

125-
`Builder.responseDecorator(...)` registers a `ResponseDecorator` that runs whenever a handler calls `request.respond(status)`. Decorators set headers (or other pre-terminal state) before the handler reaches a terminal call. They run in registration order, and handler-supplied headers override decorator-supplied ones (last write wins).
126-
127-
Use cases: stamping correlation IDs, tenant IDs, server identifiers, or any cross-cutting header on every response.
152+
`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(...)`.
128153

129154
``` java
130155
OpenApiServer.builder()
131156
.spec(spec)
132157
.handlers(handlers)
133-
.responseDecorator(
134-
(request, response) ->
135-
response.header("X-Correlation-Id", CorrelationId.current()))
136-
.responseDecorator(
137-
(request, response) -> response.header("X-Tenant-Id", TenantId.current()))
158+
.responseDecorator((req, resp) -> resp.withHeader("X-Correlation-Id", CorrelationId.current()))
159+
.responseDecorator((req, resp) -> resp.withHeader("X-Tenant-Id", TenantId.current()))
138160
.build();
139161
```
140162

141-
`ResponseDecorator` is a `@FunctionalInterface`; the lambda receives the `Request` and the `ResponseBuilder` that's about to be returned from `respond(...)`. Don't call a terminal method (`empty()` / `bytes()` / `json()` / ...) from a decorator — terminals belong to the handler.
142-
143163
### Request interceptors
144164

145-
`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.
165+
`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`).
146166

147167
``` java
148168
OpenApiServer.builder()
149169
.spec(spec)
150170
.handlers(handlers)
151-
.interceptor(
152-
(request, next) -> {
153-
// Resolve once per request.
154-
String tenant = request.header("X-Tenant-Id");
155-
ScopedValue.where(TENANT, tenant)
156-
.call(
157-
() -> {
158-
next.proceed();
159-
return null;
160-
});
161-
})
162-
.interceptor(
163-
(request, next) -> {
164-
MDC.put("op", request.operationId());
165-
try {
166-
next.proceed();
167-
} finally {
168-
MDC.remove("op");
169-
}
170-
})
171+
.interceptor((request, next) -> {
172+
// Resolve once per request; bind to a ScopedValue for the rest of the chain.
173+
String tenant = request.header("X-Tenant-Id");
174+
return ScopedValue.where(TENANT, tenant).call(next::proceed);
175+
})
176+
.interceptor((request, next) -> {
177+
MDC.put("op", request.operationId());
178+
try {
179+
return next.proceed();
180+
} finally {
181+
MDC.remove("op");
182+
}
183+
})
171184
.build();
172185
```
173186

174-
Each interceptor must call `next.proceed()` to continue the chain. Exceptions propagate to the library's standard `ExceptionFilter` and `ExceptionHandler` pipeline.
187+
Exceptions propagate to the library's standard `ExceptionFilter` and `ExceptionHandler` pipeline.
175188

176189
### Combining interceptors and decorators
177190

178-
The two collaborate naturally: the interceptor binds per-request context once, and the decorator reads that context when stamping response headers. Handlers stay free of cross-cutting code.
191+
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.
179192

180193
``` java
181194
// Per-request context populated by the interceptor, read by the decorator and handlers.
@@ -186,44 +199,36 @@ OpenApiServer.builder()
186199
.spec(spec)
187200
.handlers(handlers)
188201
// 1. Resolve once per request and bind to ScopedValues.
189-
.interceptor(
190-
(request, next) -> {
191-
String correlationId =
192-
Optional.ofNullable(request.header("X-Correlation-Id"))
193-
.orElseGet(() -> UUID.randomUUID().toString());
194-
String tenantId = resolveTenant(request);
195-
ScopedValue.where(CORRELATION_ID, correlationId)
196-
.where(TENANT_ID, tenantId)
197-
.call(
198-
() -> {
199-
next.proceed();
200-
return null;
201-
});
202-
})
202+
.interceptor((request, next) -> {
203+
String correlationId =
204+
Optional.ofNullable(request.header("X-Correlation-Id"))
205+
.orElseGet(() -> UUID.randomUUID().toString());
206+
String tenantId = resolveTenant(request);
207+
return ScopedValue.where(CORRELATION_ID, correlationId)
208+
.where(TENANT_ID, tenantId)
209+
.call(next::proceed);
210+
})
203211
// 2. Stamp those values on every response.
204-
.responseDecorator(
205-
(request, response) -> {
206-
response.header("X-Correlation-Id", CORRELATION_ID.get());
207-
response.header("X-Tenant-Id", TENANT_ID.get());
208-
})
212+
.responseDecorator((req, resp) -> resp
213+
.withHeader("X-Correlation-Id", CORRELATION_ID.get())
214+
.withHeader("X-Tenant-Id", TENANT_ID.get()))
209215
.build();
210216
```
211217

212-
Inside any handler, `CORRELATION_ID.get()` / `TENANT_ID.get()` return the resolved values — no parameter threading, no static accessors. Because the decorator runs *inside* the interceptor's `ScopedValue` binding (decorators fire on `request.respond(...)`, which the handler calls while the interceptor's `proceed()` is still on the stack), the `get()` calls always see the bound value.
218+
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.
213219

214220
A handler in this setup is just business logic:
215221

216222
``` java
217223
public class GetPromotionHandler implements RequestHandler {
218224
@Override
219-
public void handle(Request request) throws IOException {
225+
public Response handle(Request request) {
220226
String id = request.pathParams().get("id");
221227
String tenant = TENANT_ID.get();
222-
promotionService
228+
return promotionService
223229
.find(tenant, id)
224-
.ifPresentOrElse(
225-
promotion -> request.respond(HTTP_OK).json(promotion),
226-
() -> request.respond(HTTP_NOT_FOUND).empty());
230+
.map(p -> Response.of(HTTP_OK, p))
231+
.orElse(Response.status(HTTP_NOT_FOUND));
227232
}
228233
}
229234
```
@@ -330,7 +335,7 @@ try (var server = OpenApiServer.builder()
330335
- OpenAPI specification support
331336
- Automatic request body parsing and response writing per media type via `TypeMapper`
332337
- `RequestHandler` functional interface — a single `handle(Request)` method replaces raw `HttpExchange` manipulation
333-
- Fluent `ResponseBuilder` via `request.respond(status)` with terminals: `empty()`, `bytes()`, `text()`, `json()`, `body()`, `stream()`
338+
- 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(...)`
334339
- Built-in `GsonJsonMapper` auto-registered when Gson is on the classpath (no explicit wiring needed)
335340
- `ResponseDecorator` for cross-cutting response headers and `RequestInterceptor` for around-style ScopedValue / MDC / auth concerns
336341
- Built on Java's native `HttpServer` with Thread-Per-Request behaviour using Virtual Threads
@@ -358,6 +363,6 @@ A few things to know:
358363
- **Single-process model.** No horizontal scaling primitives are bundled; run multiple instances behind a load balancer for production scale.
359364
- **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.
360365
- **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.
361-
- **Empty responses must use `request.respond(status).empty()`**, which sends `responseLength = -1` (`Content-Length: 0`, no body). Passing `0` produces a chunked response with zero chunks, which is technically non-conformant.
366+
- **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.
362367

363368
## Known limitations or missing features

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

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import com.retailsvc.http.internal.ExceptionFilter;
99
import com.retailsvc.http.internal.FormTypeMapper;
1010
import com.retailsvc.http.internal.RequestPreparationFilter;
11+
import com.retailsvc.http.internal.ResponseRenderer;
1112
import com.retailsvc.http.internal.Router;
1213
import com.retailsvc.http.internal.TextTypeMapper;
1314
import com.retailsvc.http.spec.Spec;
@@ -71,9 +72,9 @@ public class OpenApiServer implements AutoCloseable {
7172

7273
HttpContext ctx = httpServer.createContext(Optional.ofNullable(spec.basePath()).orElse("/"));
7374
ctx.getFilters().add(new ExceptionFilter(exceptionHandler));
74-
ctx.getFilters()
75-
.add(new RequestPreparationFilter(spec, router, validator, bodyMappers, decorators));
76-
ctx.setHandler(new DispatchHandler(handlers, interceptors));
75+
ctx.getFilters().add(new RequestPreparationFilter(spec, router, validator, bodyMappers));
76+
ctx.setHandler(
77+
new DispatchHandler(handlers, interceptors, decorators, new ResponseRenderer(bodyMappers)));
7778

7879
for (Map.Entry<String, HttpHandler> e : extras.entrySet()) {
7980
HttpContext extraCtx = httpServer.createContext(e.getKey());
@@ -150,9 +151,9 @@ public Builder handlers(Map<String, RequestHandler> handlers) {
150151
}
151152

152153
/**
153-
* Registers a {@link ResponseDecorator} that mutates the {@link ResponseBuilder} returned by
154-
* {@link Request#respond(int)} before the handler receives it. Decorators run in registration
155-
* order; handler-supplied headers override decorator-supplied ones.
154+
* Registers a {@link ResponseDecorator} that transforms the {@link Response} returned by the
155+
* handler before it is rendered. Decorators compose in registration order; decorator-supplied
156+
* headers override handler-supplied ones on conflict.
156157
*/
157158
public Builder responseDecorator(ResponseDecorator decorator) {
158159
decorators.add(requireNonNull(decorator, "decorator must not be null"));

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

Lines changed: 4 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
11
package com.retailsvc.http;
22

3-
import com.retailsvc.http.internal.DefaultResponseBuilder;
43
import com.sun.net.httpserver.HttpExchange;
54
import java.net.URLDecoder;
65
import java.nio.charset.StandardCharsets;
76
import java.util.LinkedHashMap;
8-
import java.util.List;
97
import java.util.Map;
108

119
/**
12-
* The per-request handle passed to {@link RequestHandler}. Carries the parsed body, path
13-
* parameters, operation ID, and a fluent {@link ResponseBuilder} for writing the response.
10+
* Read-only per-request handle passed to {@link RequestHandler}. Carries the parsed body, path
11+
* parameters, query parameters, headers, and operation ID. Handlers consume a {@code Request} and
12+
* return a {@link Response}.
1413
*/
1514
public final class Request {
1615

@@ -19,26 +18,19 @@ public final class Request {
1918
private final Object parsed;
2019
private final String operationId;
2120
private final Map<String, String> pathParameters;
22-
private final Map<String, TypeMapper> bodyMappers;
23-
private final List<ResponseDecorator> decorators;
2421
private Map<String, String> queryParamCache;
25-
private boolean responseSent;
2622

2723
public Request(
2824
HttpExchange exchange,
2925
byte[] body,
3026
Object parsed,
3127
String operationId,
32-
Map<String, String> pathParameters,
33-
Map<String, TypeMapper> bodyMappers,
34-
List<ResponseDecorator> decorators) {
28+
Map<String, String> pathParameters) {
3529
this.exchange = exchange;
3630
this.body = body;
3731
this.parsed = parsed;
3832
this.operationId = operationId;
3933
this.pathParameters = pathParameters;
40-
this.bodyMappers = bodyMappers;
41-
this.decorators = List.copyOf(decorators);
4234
}
4335

4436
public byte[] bytes() {
@@ -103,16 +95,4 @@ private static Map<String, String> parseQuery(String query) {
10395
}
10496
return out;
10597
}
106-
107-
public ResponseBuilder respond(int status) {
108-
if (responseSent) {
109-
throw new IllegalStateException("Response already sent");
110-
}
111-
ResponseBuilder builder =
112-
new DefaultResponseBuilder(exchange, status, bodyMappers, () -> responseSent = true);
113-
for (ResponseDecorator decorator : decorators) {
114-
decorator.decorate(this, builder);
115-
}
116-
return builder;
117-
}
11898
}
Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
package com.retailsvc.http;
22

3-
import java.io.IOException;
4-
53
/**
64
* Handles a single request identified by OpenAPI {@code operationId}. Registered on {@link
75
* OpenApiServer.Builder#handlers(java.util.Map)} by operation ID.
6+
*
7+
* <p>Handlers are pure functions of the {@link Request}: they read inputs and return a {@link
8+
* Response} describing what should be sent. The framework renders the response after applying any
9+
* registered {@link ResponseDecorator}s. Handlers may throw any {@link RuntimeException}; the
10+
* configured {@link ExceptionHandler} renders it. Handlers that need to surface an {@code
11+
* IOException} should wrap it as {@link java.io.UncheckedIOException}.
812
*/
913
@FunctionalInterface
1014
public interface RequestHandler {
11-
void handle(Request request) throws IOException;
15+
Response handle(Request request);
1216
}
Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,21 @@
11
package com.retailsvc.http;
22

3-
import java.io.IOException;
4-
53
/**
64
* Wraps the {@link RequestHandler} invocation. Use for {@link ScopedValue} bindings, MDC, tracing,
75
* authentication, or any other concern that should run uniformly around every handler.
86
*
97
* <p>Interceptors compose in registration order: the first registered runs outermost. Each
10-
* interceptor must call {@link Continuation#proceed()} to invoke the next interceptor (or the
11-
* handler, when last). Exceptions propagate to the library's standard {@code ExceptionFilter} and
12-
* {@code ExceptionHandler} pipeline.
8+
* interceptor must call {@link Continuation#proceed()} and return its result (or a transformed
9+
* value). Exceptions propagate to the configured {@link ExceptionHandler}.
1310
*/
1411
@FunctionalInterface
1512
public interface RequestInterceptor {
1613

17-
void around(Request request, Continuation next) throws IOException;
14+
Response around(Request request, Continuation next);
1815

1916
/** Continues the chain — calls the next interceptor, or the handler if this is the last one. */
2017
@FunctionalInterface
2118
interface Continuation {
22-
void proceed() throws IOException;
19+
Response proceed();
2320
}
2421
}

0 commit comments

Comments
 (0)