Skip to content

Commit 791203c

Browse files
committed
refactor!: Decouple Request from HttpExchange to enable swappable transports
Request now holds transport-neutral primitives: body bytes, parsed body, TypeMapper, operationId, path parameters, raw query string, and a Function<String,String> header lookup. The JDK HttpServer adapter (RequestPreparationFilter) is the only place that touches HttpExchange. Why: keeps the door open to a Netty / Helidon Nima / Jetty backend later if JDK HttpServer's throughput becomes the bottleneck. The handler-facing API (Request, Response, RequestHandler, RequestInterceptor, ResponseDecorator, TypeMapper) is now genuinely transport-neutral — a future adapter would live in com.retailsvc.http.internal and leave handler code untouched. README's 'Performance and caveats' section documents the rationale.
1 parent 0840082 commit 791203c

5 files changed

Lines changed: 98 additions & 54 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,7 @@ The library wraps the JDK's bundled `com.sun.net.httpserver.HttpServer` and uses
433433
A few things to know:
434434

435435
- **Single-process model.** No horizontal scaling primitives are bundled; run multiple instances behind a load balancer for production scale.
436-
- **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.
436+
- **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.
437437
- **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.
438438
- **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.
439439

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

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,64 @@
11
package com.retailsvc.http;
22

3-
import com.sun.net.httpserver.HttpExchange;
43
import java.net.URLDecoder;
54
import java.nio.charset.StandardCharsets;
65
import java.util.LinkedHashMap;
76
import java.util.Map;
87
import java.util.Objects;
98
import java.util.Optional;
9+
import java.util.function.Function;
1010

1111
/**
1212
* Read-only per-request handle passed to {@link RequestHandler}. Carries the parsed body, path
13-
* parameters, query parameters, headers, and operation ID. Handlers consume a {@code Request} and
14-
* return a {@link Response}.
13+
* parameters, query parameters, headers, and operation ID.
14+
*
15+
* <p>{@code Request} is transport-neutral: it holds the body bytes, the raw query string, the path
16+
* parameter map, and a header lookup function. The transport adapter (today the built-in JDK {@code
17+
* HttpServer}, tomorrow potentially Netty or another backend) is responsible for extracting those
18+
* primitives from its own request representation. Handlers consume a {@code Request} and return a
19+
* {@link Response}.
1520
*/
1621
public final class Request {
1722

1823
private static final String CONTENT_TYPE = "Content-Type";
1924

20-
private final HttpExchange exchange;
2125
private final byte[] body;
2226
private final Object parsed;
2327
private final TypeMapper bodyMapper;
2428
private final String operationId;
2529
private final Map<String, String> pathParameters;
30+
private final String rawQuery;
31+
private final Function<String, String> headerLookup;
2632
private Map<String, String> queryParamCache;
2733

34+
/**
35+
* Builds a {@code Request} from transport-neutral primitives. Adapters call this; handlers
36+
* receive the constructed instance.
37+
*
38+
* @param body raw request body bytes; never {@code null}, may be empty
39+
* @param parsed loose structural view of the body (Map / List / boxed primitive), or {@code null}
40+
* @param bodyMapper {@link TypeMapper} that produced {@code parsed}, used for typed conversion;
41+
* may be {@code null} if there is no body
42+
* @param operationId the OpenAPI {@code operationId} the request was routed to
43+
* @param pathParameters path variables extracted by the router
44+
* @param rawQuery raw (percent-encoded) query string, or {@code null} if absent
45+
* @param headerLookup first-value, case-insensitive header lookup; returns {@code null} if absent
46+
*/
2847
public Request(
29-
HttpExchange exchange,
3048
byte[] body,
3149
Object parsed,
3250
TypeMapper bodyMapper,
3351
String operationId,
34-
Map<String, String> pathParameters) {
35-
this.exchange = exchange;
52+
Map<String, String> pathParameters,
53+
String rawQuery,
54+
Function<String, String> headerLookup) {
3655
this.body = body;
3756
this.parsed = parsed;
3857
this.bodyMapper = bodyMapper;
3958
this.operationId = operationId;
4059
this.pathParameters = pathParameters;
60+
this.rawQuery = rawQuery;
61+
this.headerLookup = headerLookup;
4162
}
4263

4364
public byte[] bytes() {
@@ -71,7 +92,7 @@ public <T> T asPojo(Class<T> type) {
7192
if (parsed != null && type.isInstance(parsed)) {
7293
return type.cast(parsed);
7394
}
74-
String contentType = exchange.getRequestHeaders().getFirst(CONTENT_TYPE);
95+
String contentType = headerLookup.apply(CONTENT_TYPE);
7596
if (bodyMapper instanceof TypedTypeMapper typed) {
7697
return typed.readAs(body, contentType, type);
7798
}
@@ -100,7 +121,7 @@ public String pathParam(String name) {
100121
* without the extra {@code filter(v -> !v.isBlank())} step.
101122
*/
102123
public Optional<String> header(String name) {
103-
String raw = exchange.getRequestHeaders().getFirst(name);
124+
String raw = headerLookup.apply(name);
104125
return raw == null || raw.isBlank() ? Optional.empty() : Optional.of(raw);
105126
}
106127

@@ -109,7 +130,7 @@ public Optional<String> header(String name) {
109130
* query component.
110131
*/
111132
public String rawQuery() {
112-
return exchange.getRequestURI().getRawQuery();
133+
return rawQuery;
113134
}
114135

115136
/**
@@ -118,7 +139,7 @@ public String rawQuery() {
118139
*/
119140
public Map<String, String> queryParams() {
120141
if (queryParamCache == null) {
121-
queryParamCache = parseQuery(rawQuery());
142+
queryParamCache = parseQuery(rawQuery);
122143
}
123144
return queryParamCache;
124145
}

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,16 @@ public void doFilter(HttpExchange exchange, Chain chain) throws IOException {
6464
validateParameters(exchange, op, match.pathParameters());
6565
ParsedBody parsedBody = validateAndParseBody(exchange, op, body);
6666

67+
var headers = exchange.getRequestHeaders();
6768
Request request =
6869
new Request(
69-
exchange,
7070
body,
7171
parsedBody.value(),
7272
parsedBody.mapper(),
7373
op.operationId(),
74-
match.pathParameters());
74+
match.pathParameters(),
75+
exchange.getRequestURI().getRawQuery(),
76+
headers::getFirst);
7577

7678
try {
7779
ScopedValue.where(DispatchHandler.CURRENT, request)

src/test/java/com/retailsvc/http/RequestTest.java

Lines changed: 60 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,38 @@
22

33
import static org.assertj.core.api.Assertions.assertThat;
44
import static org.assertj.core.api.Assertions.assertThatThrownBy;
5-
import static org.mockito.Mockito.mock;
6-
import static org.mockito.Mockito.when;
75

86
import com.fasterxml.jackson.databind.ObjectMapper;
97
import com.retailsvc.http.internal.DispatchHandler;
10-
import com.sun.net.httpserver.Headers;
11-
import com.sun.net.httpserver.HttpExchange;
12-
import java.net.URI;
138
import java.nio.charset.StandardCharsets;
149
import java.util.Map;
1510
import java.util.concurrent.atomic.AtomicReference;
11+
import java.util.function.Function;
1612
import org.junit.jupiter.api.Test;
1713

1814
class RequestTest {
1915

16+
private static final Function<String, String> NO_HEADERS = name -> null;
17+
18+
private static Function<String, String> headers(String... pairs) {
19+
Map<String, String> map = new java.util.HashMap<>();
20+
for (int i = 0; i < pairs.length; i += 2) {
21+
map.put(pairs[i].toLowerCase(), pairs[i + 1]);
22+
}
23+
return name -> map.get(name.toLowerCase());
24+
}
25+
2026
@Test
2127
void readsBoundContext() throws Exception {
22-
HttpExchange exchange = mock(HttpExchange.class);
2328
Request req =
2429
new Request(
25-
exchange, new byte[] {1, 2, 3}, Map.of("k", "v"), null, "get-x", Map.of("id", "42"));
30+
new byte[] {1, 2, 3},
31+
Map.of("k", "v"),
32+
null,
33+
"get-x",
34+
Map.of("id", "42"),
35+
null,
36+
NO_HEADERS);
2637

2738
AtomicReference<byte[]> seenBytes = new AtomicReference<>();
2839
AtomicReference<Object> seenParsed = new AtomicReference<>();
@@ -48,14 +59,17 @@ void readsBoundContext() throws Exception {
4859

4960
@Test
5061
void asPojoDeserialisesViaTypedMapper() {
51-
HttpExchange exchange = mock(HttpExchange.class);
52-
Headers headers = new Headers();
53-
headers.add("Content-Type", "application/json");
54-
when(exchange.getRequestHeaders()).thenReturn(headers);
5562
JacksonJsonTypeMapper mapper = new JacksonJsonTypeMapper(new ObjectMapper());
5663
byte[] body = "{\"id\":\"x-1\",\"qty\":7}".getBytes(StandardCharsets.UTF_8);
5764
Request req =
58-
new Request(exchange, body, Map.of("id", "x-1", "qty", 7), mapper, "op", Map.of());
65+
new Request(
66+
body,
67+
Map.of("id", "x-1", "qty", 7),
68+
mapper,
69+
"op",
70+
Map.of(),
71+
null,
72+
headers("Content-Type", "application/json"));
5973

6074
Item item = req.asPojo(Item.class);
6175

@@ -65,18 +79,17 @@ void asPojoDeserialisesViaTypedMapper() {
6579

6680
@Test
6781
void asPojoFastPathWhenParsedAlreadyMatchesType() {
68-
HttpExchange exchange = mock(HttpExchange.class);
6982
Map<String, Object> alreadyParsed = Map.of("k", "v");
70-
Request req = new Request(exchange, "x".getBytes(), alreadyParsed, null, "op", Map.of());
83+
Request req =
84+
new Request("x".getBytes(), alreadyParsed, null, "op", Map.of(), null, NO_HEADERS);
7185

7286
Map<?, ?> result = req.asPojo(Map.class);
7387
assertThat(result).isSameAs(alreadyParsed);
7488
}
7589

7690
@Test
7791
void asPojoThrowsWhenBodyEmpty() {
78-
HttpExchange exchange = mock(HttpExchange.class);
79-
Request req = new Request(exchange, new byte[0], null, null, "op", Map.of());
92+
Request req = new Request(new byte[0], null, null, "op", Map.of(), null, NO_HEADERS);
8093

8194
assertThatThrownBy(() -> req.asPojo(Item.class))
8295
.isInstanceOf(IllegalStateException.class)
@@ -85,10 +98,6 @@ void asPojoThrowsWhenBodyEmpty() {
8598

8699
@Test
87100
void asPojoThrowsWhenMapperNotTyped() {
88-
HttpExchange exchange = mock(HttpExchange.class);
89-
Headers headers = new Headers();
90-
headers.add("Content-Type", "text/plain");
91-
when(exchange.getRequestHeaders()).thenReturn(headers);
92101
TypeMapper plain =
93102
new TypeMapper() {
94103
@Override
@@ -101,7 +110,15 @@ public byte[] writeTo(Object v) {
101110
return v.toString().getBytes(StandardCharsets.UTF_8);
102111
}
103112
};
104-
Request req = new Request(exchange, "hello".getBytes(), "hello", plain, "op", Map.of());
113+
Request req =
114+
new Request(
115+
"hello".getBytes(),
116+
"hello",
117+
plain,
118+
"op",
119+
Map.of(),
120+
null,
121+
headers("Content-Type", "text/plain"));
105122

106123
assertThatThrownBy(() -> req.asPojo(Item.class))
107124
.isInstanceOf(IllegalStateException.class)
@@ -115,19 +132,23 @@ static final class Item {
115132

116133
@Test
117134
void pathParamReturnsValueOrNull() {
118-
HttpExchange exchange = mock(HttpExchange.class);
119-
Request req = new Request(exchange, new byte[0], null, null, "op", Map.of("id", "42"));
135+
Request req = new Request(new byte[0], null, null, "op", Map.of("id", "42"), null, NO_HEADERS);
120136

121137
assertThat(req.pathParam("id")).isEqualTo("42");
122138
assertThat(req.pathParam("missing")).isNull();
123139
}
124140

125141
@Test
126142
void exposesQueryParams() {
127-
HttpExchange exchange = mock(HttpExchange.class);
128-
when(exchange.getRequestURI())
129-
.thenReturn(URI.create("http://h/x?name=Alice%20Smith&active=true&active=false"));
130-
Request req = new Request(exchange, new byte[0], null, null, "op", Map.of());
143+
Request req =
144+
new Request(
145+
new byte[0],
146+
null,
147+
null,
148+
"op",
149+
Map.of(),
150+
"name=Alice%20Smith&active=true&active=false",
151+
NO_HEADERS);
131152

132153
assertThat(req.rawQuery()).isEqualTo("name=Alice%20Smith&active=true&active=false");
133154
assertThat(req.queryParam("name")).contains("Alice Smith");
@@ -140,9 +161,7 @@ void exposesQueryParams() {
140161

141162
@Test
142163
void queryParamsEmptyWhenNoQuery() {
143-
HttpExchange exchange = mock(HttpExchange.class);
144-
when(exchange.getRequestURI()).thenReturn(URI.create("http://h/x"));
145-
Request req = new Request(exchange, new byte[0], null, null, "op", Map.of());
164+
Request req = new Request(new byte[0], null, null, "op", Map.of(), null, NO_HEADERS);
146165

147166
assertThat(req.rawQuery()).isNull();
148167
assertThat(req.queryParams()).isEmpty();
@@ -151,22 +170,24 @@ void queryParamsEmptyWhenNoQuery() {
151170

152171
@Test
153172
void queryParamBlankIsTreatedAsAbsent() {
154-
HttpExchange exchange = mock(HttpExchange.class);
155-
when(exchange.getRequestURI()).thenReturn(URI.create("http://h/x?limit=&offset=%20"));
156-
Request req = new Request(exchange, new byte[0], null, null, "op", Map.of());
173+
Request req =
174+
new Request(new byte[0], null, null, "op", Map.of(), "limit=&offset=%20", NO_HEADERS);
157175

158176
assertThat(req.queryParam("limit")).isEmpty();
159177
assertThat(req.queryParam("offset")).isEmpty();
160178
}
161179

162180
@Test
163181
void headerReturnsOptionalAndBlankIsAbsent() {
164-
HttpExchange exchange = mock(HttpExchange.class);
165-
com.sun.net.httpserver.Headers h = new com.sun.net.httpserver.Headers();
166-
h.add("X-Trace", "abc");
167-
h.add("X-Empty", " ");
168-
when(exchange.getRequestHeaders()).thenReturn(h);
169-
Request req = new Request(exchange, new byte[0], null, null, "op", Map.of());
182+
Request req =
183+
new Request(
184+
new byte[0],
185+
null,
186+
null,
187+
"op",
188+
Map.of(),
189+
null,
190+
headers("X-Trace", "abc", "X-Empty", " "));
170191

171192
assertThat(req.header("X-Trace")).contains("abc");
172193
assertThat(req.header("X-Empty")).isEmpty();

src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ private static DispatchHandler dispatcher(Map<String, RequestHandler> handlers)
3232
private static void withRequest(
3333
HttpExchange exchange, String operationId, ScopedValue.CallableOp<Void, Exception> body)
3434
throws Exception {
35-
Request req = new Request(exchange, new byte[0], null, null, operationId, Map.of());
35+
Request req = new Request(new byte[0], null, null, operationId, Map.of(), null, n -> null);
3636
ScopedValue.where(DispatchHandler.CURRENT, req).call(body);
3737
}
3838

0 commit comments

Comments
 (0)