Skip to content

Commit 48f7f0d

Browse files
committed
feat: Add ResponseDecorator and RequestInterceptor for cross-cutting concerns
1 parent f1e6ed1 commit 48f7f0d

11 files changed

Lines changed: 304 additions & 17 deletions

README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,59 @@ Register a custom mapper for any media type via `Builder.bodyMapper(mediaType, m
120120

121121
User-supplied mappers take precedence over built-in defaults, so you can override any of the above.
122122

123+
### Response decorators
124+
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.
128+
129+
``` java
130+
OpenApiServer.builder()
131+
.spec(spec)
132+
.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()))
138+
.build();
139+
```
140+
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+
143+
### Request interceptors
144+
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.
146+
147+
``` java
148+
OpenApiServer.builder()
149+
.spec(spec)
150+
.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+
.build();
172+
```
173+
174+
Each interceptor must call `next.proceed()` to continue the chain. Exceptions propagate to the library's standard `ExceptionFilter` and `ExceptionHandler` pipeline.
175+
123176
### Request body content types
124177

125178
The server reads `requestBody.content` from the spec and selects a mapper by the request's media type (the bare `type/subtype` from `Content-Type`, e.g. `application/json`; lookup is case-insensitive):
@@ -224,6 +277,7 @@ try (var server = OpenApiServer.builder()
224277
- `RequestHandler` functional interface — a single `handle(Request)` method replaces raw `HttpExchange` manipulation
225278
- Fluent `ResponseBuilder` via `request.respond(status)` with terminals: `empty()`, `bytes()`, `text()`, `json()`, `body()`, `stream()`
226279
- Built-in `GsonJsonMapper` auto-registered when Gson is on the classpath (no explicit wiring needed)
280+
- `ResponseDecorator` for cross-cutting response headers and `RequestInterceptor` for around-style ScopedValue / MDC / auth concerns
227281
- Built on Java's native `HttpServer` with Thread-Per-Request behaviour using Virtual Threads
228282

229283

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

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
import com.sun.net.httpserver.HttpServer;
1818
import java.io.IOException;
1919
import java.net.InetSocketAddress;
20+
import java.util.ArrayList;
2021
import java.util.LinkedHashMap;
22+
import java.util.List;
2123
import java.util.Locale;
2224
import java.util.Map;
2325
import java.util.Optional;
@@ -44,6 +46,8 @@ public class OpenApiServer implements AutoCloseable {
4446
Spec spec,
4547
Map<String, TypeMapper> bodyMappers,
4648
Map<String, RequestHandler> handlers,
49+
List<ResponseDecorator> decorators,
50+
List<RequestInterceptor> interceptors,
4751
ExceptionHandler exceptionHandler,
4852
int port,
4953
Map<String, HttpHandler> extras,
@@ -67,8 +71,9 @@ public class OpenApiServer implements AutoCloseable {
6771

6872
HttpContext ctx = httpServer.createContext(Optional.ofNullable(spec.basePath()).orElse("/"));
6973
ctx.getFilters().add(new ExceptionFilter(exceptionHandler));
70-
ctx.getFilters().add(new RequestPreparationFilter(spec, router, validator, bodyMappers));
71-
ctx.setHandler(new DispatchHandler(handlers));
74+
ctx.getFilters()
75+
.add(new RequestPreparationFilter(spec, router, validator, bodyMappers, decorators));
76+
ctx.setHandler(new DispatchHandler(handlers, interceptors));
7277

7378
for (Map.Entry<String, HttpHandler> e : extras.entrySet()) {
7479
HttpContext extraCtx = httpServer.createContext(e.getKey());
@@ -118,6 +123,8 @@ public static final class Builder {
118123
private Spec spec;
119124
private final LinkedHashMap<String, TypeMapper> bodyMappers = new LinkedHashMap<>();
120125
private Map<String, RequestHandler> handlers;
126+
private final List<ResponseDecorator> decorators = new ArrayList<>();
127+
private final List<RequestInterceptor> interceptors = new ArrayList<>();
121128
private ExceptionHandler exceptionHandler;
122129
private int port = DEFAULT_PORT;
123130
private int shutdownTimeoutSeconds = 0;
@@ -142,6 +149,25 @@ public Builder handlers(Map<String, RequestHandler> handlers) {
142149
return this;
143150
}
144151

152+
/**
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.
156+
*/
157+
public Builder responseDecorator(ResponseDecorator decorator) {
158+
decorators.add(requireNonNull(decorator, "decorator must not be null"));
159+
return this;
160+
}
161+
162+
/**
163+
* Registers a {@link RequestInterceptor} that wraps the handler invocation. Interceptors run in
164+
* registration order; the first registered is the outermost.
165+
*/
166+
public Builder interceptor(RequestInterceptor interceptor) {
167+
interceptors.add(requireNonNull(interceptor, "interceptor must not be null"));
168+
return this;
169+
}
170+
145171
public Builder exceptionHandler(ExceptionHandler exceptionHandler) {
146172
this.exceptionHandler = exceptionHandler;
147173
return this;
@@ -188,7 +214,15 @@ public OpenApiServer build() throws IOException {
188214
}
189215
Map<String, TypeMapper> resolved = resolveBodyMappers(bodyMappers);
190216
return new OpenApiServer(
191-
spec, resolved, handlers, exceptionHandler, port, extras, shutdownTimeoutSeconds);
217+
spec,
218+
resolved,
219+
handlers,
220+
decorators,
221+
interceptors,
222+
exceptionHandler,
223+
port,
224+
extras,
225+
shutdownTimeoutSeconds);
192226
}
193227

194228
private static Map<String, TypeMapper> resolveBodyMappers(

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import java.net.URLDecoder;
66
import java.nio.charset.StandardCharsets;
77
import java.util.LinkedHashMap;
8+
import java.util.List;
89
import java.util.Map;
910

1011
/**
@@ -19,6 +20,7 @@ public final class Request {
1920
private final String operationId;
2021
private final Map<String, String> pathParameters;
2122
private final Map<String, TypeMapper> bodyMappers;
23+
private final List<ResponseDecorator> decorators;
2224
private Map<String, String> queryParamCache;
2325
private boolean responseSent;
2426

@@ -28,13 +30,15 @@ public Request(
2830
Object parsed,
2931
String operationId,
3032
Map<String, String> pathParameters,
31-
Map<String, TypeMapper> bodyMappers) {
33+
Map<String, TypeMapper> bodyMappers,
34+
List<ResponseDecorator> decorators) {
3235
this.exchange = exchange;
3336
this.body = body;
3437
this.parsed = parsed;
3538
this.operationId = operationId;
3639
this.pathParameters = pathParameters;
3740
this.bodyMappers = bodyMappers;
41+
this.decorators = List.copyOf(decorators);
3842
}
3943

4044
public byte[] bytes() {
@@ -104,6 +108,11 @@ public ResponseBuilder respond(int status) {
104108
if (responseSent) {
105109
throw new IllegalStateException("Response already sent");
106110
}
107-
return new DefaultResponseBuilder(exchange, status, bodyMappers, () -> responseSent = true);
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;
108117
}
109118
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.retailsvc.http;
2+
3+
import java.io.IOException;
4+
5+
/**
6+
* Wraps the {@link RequestHandler} invocation. Use for {@link ScopedValue} bindings, MDC, tracing,
7+
* authentication, or any other concern that should run uniformly around every handler.
8+
*
9+
* <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.
13+
*/
14+
@FunctionalInterface
15+
public interface RequestInterceptor {
16+
17+
void around(Request request, Continuation next) throws IOException;
18+
19+
/** Continues the chain — calls the next interceptor, or the handler if this is the last one. */
20+
@FunctionalInterface
21+
interface Continuation {
22+
void proceed() throws IOException;
23+
}
24+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.retailsvc.http;
2+
3+
/**
4+
* Mutates the {@link ResponseBuilder} returned by {@link Request#respond(int)} before the handler
5+
* receives it. Decorators run in registration order. They may set headers (including {@code
6+
* Content-Type}) but must not call a terminal method — terminals belong to the handler.
7+
*
8+
* <p>Decorators fire before the handler runs, so any headers the handler sets via the returned
9+
* builder override decorator-supplied values.
10+
*/
11+
@FunctionalInterface
12+
public interface ResponseDecorator {
13+
void decorate(Request request, ResponseBuilder builder);
14+
}

src/main/java/com/retailsvc/http/internal/DispatchHandler.java

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,24 @@
33
import com.retailsvc.http.MissingOperationHandlerException;
44
import com.retailsvc.http.Request;
55
import com.retailsvc.http.RequestHandler;
6+
import com.retailsvc.http.RequestInterceptor;
67
import com.sun.net.httpserver.HttpExchange;
78
import com.sun.net.httpserver.HttpHandler;
89
import java.io.IOException;
10+
import java.util.List;
911
import java.util.Map;
1012

1113
public final class DispatchHandler implements HttpHandler {
1214

1315
public static final ScopedValue<Request> CURRENT = ScopedValue.newInstance();
1416

1517
private final Map<String, RequestHandler> handlers;
18+
private final List<RequestInterceptor> interceptors;
1619

17-
public DispatchHandler(Map<String, RequestHandler> handlers) {
20+
public DispatchHandler(
21+
Map<String, RequestHandler> handlers, List<RequestInterceptor> interceptors) {
1822
this.handlers = Map.copyOf(handlers);
23+
this.interceptors = List.copyOf(interceptors);
1924
}
2025

2126
@Override
@@ -25,6 +30,14 @@ public void handle(HttpExchange exchange) throws IOException {
2530
if (h == null) {
2631
throw new MissingOperationHandlerException(request.operationId());
2732
}
28-
h.handle(request);
33+
invoke(0, request, h);
34+
}
35+
36+
private void invoke(int idx, Request request, RequestHandler handler) throws IOException {
37+
if (idx == interceptors.size()) {
38+
handler.handle(request);
39+
return;
40+
}
41+
interceptors.get(idx).around(request, () -> invoke(idx + 1, request, handler));
2942
}
3043
}

src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.retailsvc.http.MethodNotAllowedException;
44
import com.retailsvc.http.NotFoundException;
55
import com.retailsvc.http.Request;
6+
import com.retailsvc.http.ResponseDecorator;
67
import com.retailsvc.http.TypeMapper;
78
import com.retailsvc.http.ValidationException;
89
import com.retailsvc.http.spec.HttpMethod;
@@ -17,6 +18,7 @@
1718
import com.sun.net.httpserver.HttpExchange;
1819
import java.io.IOException;
1920
import java.util.HashMap;
21+
import java.util.List;
2022
import java.util.Locale;
2123
import java.util.Map;
2224
import java.util.Optional;
@@ -29,13 +31,19 @@ public final class RequestPreparationFilter extends Filter {
2931
private final Router router;
3032
private final Validator validator;
3133
private final Map<String, TypeMapper> bodyMappers;
34+
private final List<ResponseDecorator> decorators;
3235

3336
public RequestPreparationFilter(
34-
Spec spec, Router router, Validator validator, Map<String, TypeMapper> bodyMappers) {
37+
Spec spec,
38+
Router router,
39+
Validator validator,
40+
Map<String, TypeMapper> bodyMappers,
41+
List<ResponseDecorator> decorators) {
3542
this.spec = spec;
3643
this.router = router;
3744
this.validator = validator;
3845
this.bodyMappers = Map.copyOf(bodyMappers);
46+
this.decorators = List.copyOf(decorators);
3947
}
4048

4149
@Override
@@ -66,7 +74,13 @@ public void doFilter(HttpExchange exchange, Chain chain) throws IOException {
6674

6775
Request request =
6876
new Request(
69-
exchange, body, parsedBody, op.operationId(), match.pathParameters(), bodyMappers);
77+
exchange,
78+
body,
79+
parsedBody,
80+
op.operationId(),
81+
match.pathParameters(),
82+
bodyMappers,
83+
decorators);
7084

7185
try {
7286
ScopedValue.where(DispatchHandler.CURRENT, request)

0 commit comments

Comments
 (0)