Skip to content

Commit d7c3694

Browse files
committed
feat: Add TypedTypeMapper + Request.asPojo(Class) for direct POJO deserialisation
1 parent 6a1874d commit d7c3694

7 files changed

Lines changed: 170 additions & 17 deletions

File tree

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,22 @@ It is designed to be simple to use while providing the essential features needed
3636
// Inline lambda — returns JSON using the built-in Gson mapper.
3737
RequestHandler getDataHandler = req -> Response.ok(Map.of("id", "some-id"));
3838

39-
// Class form — reads raw bytes or the pre-parsed body object.
39+
// Class form — reads raw bytes, the loose Map view, or a typed POJO.
4040
public class PostDataHandler implements RequestHandler {
4141
@Override
4242
public Response handle(Request request) {
4343
// Access the raw request body bytes.
4444
byte[] body = request.bytes();
45-
// Or get the already-parsed object (Map / List) produced by the registered TypeMapper.
45+
// Loose structural view (Map / List / boxed primitives), produced by the registered TypeMapper.
4646
Object parsed = request.parsed();
47+
// Or, when the JSON mapper is Jackson (a TypedTypeMapper), get a typed POJO directly.
48+
MyDto dto = request.asPojo(MyDto.class);
4749
// Path parameters, query parameters, and headers are also available.
4850
String id = request.pathParam("id");
4951
String filter = request.queryParam("filter");
5052
String corr = request.header("correlation-id");
5153

52-
return Response.ok(parsed);
54+
return Response.ok(dto);
5355
}
5456
}
5557
```
@@ -132,7 +134,7 @@ var server = OpenApiServer.builder()
132134
.build();
133135
```
134136

135-
The same shape applies to any custom mapper — implement `TypeMapper` and register it.
137+
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)`).
136138

137139
If neither Gson is on the classpath nor any `application/json` mapper is registered, `build()` throws `IllegalStateException`.
138140

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
* fully-configured {@link ObjectMapper}; this class never adds modules or changes settings — the
1111
* mapper you pass is the mapper you get.
1212
*
13+
* <p>Implements {@link TypedTypeMapper}, so handlers can ask for a typed view of the body via
14+
* {@link Request#parsed(Class)}.
15+
*
1316
* <p>Typical wiring:
1417
*
1518
* <pre>{@code
@@ -24,7 +27,7 @@
2427
* must declare {@code jackson-databind} themselves. Consumers that use Gson can rely on the
2528
* built-in {@code GsonJsonMapper} auto-fallback instead.
2629
*/
27-
public final class JacksonJsonTypeMapper implements TypeMapper {
30+
public final class JacksonJsonTypeMapper implements TypedTypeMapper {
2831

2932
private final ObjectMapper mapper;
3033

@@ -34,8 +37,13 @@ public JacksonJsonTypeMapper(ObjectMapper mapper) {
3437

3538
@Override
3639
public Object readFrom(byte[] body, String contentTypeHeader) {
40+
return readAs(body, contentTypeHeader, Object.class);
41+
}
42+
43+
@Override
44+
public <T> T readAs(byte[] body, String contentTypeHeader, Class<T> type) {
3745
try {
38-
return mapper.readValue(body, Object.class);
46+
return mapper.readValue(body, type);
3947
} catch (IOException e) {
4048
throw new UncheckedIOException(e);
4149
}

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import java.nio.charset.StandardCharsets;
66
import java.util.LinkedHashMap;
77
import java.util.Map;
8+
import java.util.Objects;
89

910
/**
1011
* Read-only per-request handle passed to {@link RequestHandler}. Carries the parsed body, path
@@ -13,9 +14,12 @@
1314
*/
1415
public final class Request {
1516

17+
private static final String CONTENT_TYPE = "Content-Type";
18+
1619
private final HttpExchange exchange;
1720
private final byte[] body;
1821
private final Object parsed;
22+
private final TypeMapper bodyMapper;
1923
private final String operationId;
2024
private final Map<String, String> pathParameters;
2125
private Map<String, String> queryParamCache;
@@ -24,11 +28,13 @@ public Request(
2428
HttpExchange exchange,
2529
byte[] body,
2630
Object parsed,
31+
TypeMapper bodyMapper,
2732
String operationId,
2833
Map<String, String> pathParameters) {
2934
this.exchange = exchange;
3035
this.body = body;
3136
this.parsed = parsed;
37+
this.bodyMapper = bodyMapper;
3238
this.operationId = operationId;
3339
this.pathParameters = pathParameters;
3440
}
@@ -37,10 +43,42 @@ public byte[] bytes() {
3743
return body;
3844
}
3945

46+
/**
47+
* Loose structural view of the body (typically a {@code Map} / {@code List} / boxed primitive).
48+
*/
4049
public Object parsed() {
4150
return parsed;
4251
}
4352

53+
/**
54+
* Typed view of the body, deserialised into {@code type} by the request's body mapper.
55+
*
56+
* <p>Requires the registered {@link TypeMapper} for the request's {@code Content-Type} to
57+
* implement {@link TypedTypeMapper} — Jackson does, the built-in form and text mappers do not. If
58+
* the loose {@link #parsed()} value already is an instance of {@code type}, it is returned
59+
* directly without re-deserialising.
60+
*
61+
* @throws NullPointerException if {@code type} is null
62+
* @throws IllegalStateException if there is no body, or if the body mapper does not implement
63+
* {@link TypedTypeMapper}
64+
*/
65+
public <T> T asPojo(Class<T> type) {
66+
Objects.requireNonNull(type, "type must not be null");
67+
if (body == null || body.length == 0) {
68+
throw new IllegalStateException("request has no body");
69+
}
70+
if (parsed != null && type.isInstance(parsed)) {
71+
return type.cast(parsed);
72+
}
73+
if (bodyMapper instanceof TypedTypeMapper typed) {
74+
return typed.readAs(body, header(CONTENT_TYPE), type);
75+
}
76+
throw new IllegalStateException(
77+
"body mapper for "
78+
+ header(CONTENT_TYPE)
79+
+ " does not support typed conversion; the mapper must implement TypedTypeMapper");
80+
}
81+
4482
public String operationId() {
4583
return operationId;
4684
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.retailsvc.http;
2+
3+
/**
4+
* Optional capability for {@link TypeMapper}s that can deserialise a request body directly into a
5+
* caller-supplied target type. The framework uses this when handlers call {@link
6+
* Request#asPojo(Class)}; mappers that cannot meaningfully honour a target type (e.g. the built-in
7+
* form / text mappers) should not implement this interface.
8+
*
9+
* <p>Implementations should wrap any underlying {@link java.io.IOException} as a {@link
10+
* java.io.UncheckedIOException} — consistent with the surrounding {@link TypeMapper} contract.
11+
*/
12+
public interface TypedTypeMapper extends TypeMapper {
13+
14+
/**
15+
* Deserialise {@code body} into an instance of {@code type}.
16+
*
17+
* @param body raw request body bytes
18+
* @param contentTypeHeader the full raw {@code Content-Type} header (for charset / params)
19+
* @param type the target type
20+
*/
21+
<T> T readAs(byte[] body, String contentTypeHeader, Class<T> type);
22+
}

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

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,16 @@ public void doFilter(HttpExchange exchange, Chain chain) throws IOException {
6262

6363
Operation op = match.operation();
6464
validateParameters(exchange, op, match.pathParameters());
65-
Object parsedBody = validateAndParseBody(exchange, op, body);
65+
ParsedBody parsedBody = validateAndParseBody(exchange, op, body);
6666

6767
Request request =
68-
new Request(exchange, body, parsedBody, op.operationId(), match.pathParameters());
68+
new Request(
69+
exchange,
70+
body,
71+
parsedBody.value(),
72+
parsedBody.mapper(),
73+
op.operationId(),
74+
match.pathParameters());
6975

7076
try {
7177
ScopedValue.where(DispatchHandler.CURRENT, request)
@@ -119,17 +125,22 @@ private void validateParameters(
119125
}
120126
}
121127

122-
private Object validateAndParseBody(HttpExchange exchange, Operation op, byte[] body) {
128+
/** Result of {@link #validateAndParseBody}: parsed payload plus the mapper that produced it. */
129+
private record ParsedBody(Object value, TypeMapper mapper) {
130+
static final ParsedBody EMPTY = new ParsedBody(null, null);
131+
}
132+
133+
private ParsedBody validateAndParseBody(HttpExchange exchange, Operation op, byte[] body) {
123134
Optional<RequestBody> rb = op.requestBody();
124135
if (rb.isEmpty()) {
125-
return null;
136+
return ParsedBody.EMPTY;
126137
}
127138
if (body.length == 0) {
128139
if (rb.get().required()) {
129140
throw new ValidationException(
130141
new ValidationError(BODY_POINTER, "required", "request body is required", null));
131142
}
132-
return null;
143+
return ParsedBody.EMPTY;
133144
}
134145
String header = exchange.getRequestHeaders().getFirst("Content-Type");
135146
String mediaType = ContentTypeHeader.mediaType(header);
@@ -152,7 +163,7 @@ private Object validateAndParseBody(HttpExchange exchange, Operation op, byte[]
152163
parsed = FormBodyCoercion.coerce(typed, mt.schema());
153164
}
154165
validator.validate(parsed, mt.schema(), "");
155-
return parsed;
166+
return new ParsedBody(parsed, mapper);
156167
}
157168

158169
private static Map<String, String> parseQuery(String query) {

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

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

33
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
45
import static org.mockito.Mockito.mock;
56
import static org.mockito.Mockito.when;
67

8+
import com.fasterxml.jackson.databind.ObjectMapper;
79
import com.retailsvc.http.internal.DispatchHandler;
10+
import com.sun.net.httpserver.Headers;
811
import com.sun.net.httpserver.HttpExchange;
912
import java.net.URI;
13+
import java.nio.charset.StandardCharsets;
1014
import java.util.Map;
1115
import java.util.concurrent.atomic.AtomicReference;
1216
import org.junit.jupiter.api.Test;
@@ -17,7 +21,8 @@ class RequestTest {
1721
void readsBoundContext() throws Exception {
1822
HttpExchange exchange = mock(HttpExchange.class);
1923
Request req =
20-
new Request(exchange, new byte[] {1, 2, 3}, Map.of("k", "v"), "get-x", Map.of("id", "42"));
24+
new Request(
25+
exchange, new byte[] {1, 2, 3}, Map.of("k", "v"), null, "get-x", Map.of("id", "42"));
2126

2227
AtomicReference<byte[]> seenBytes = new AtomicReference<>();
2328
AtomicReference<Object> seenParsed = new AtomicReference<>();
@@ -41,10 +46,77 @@ void readsBoundContext() throws Exception {
4146
assertThat(seenPathParams.get()).containsEntry("id", "42");
4247
}
4348

49+
@Test
50+
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);
55+
JacksonJsonTypeMapper mapper = new JacksonJsonTypeMapper(new ObjectMapper());
56+
byte[] body = "{\"id\":\"x-1\",\"qty\":7}".getBytes(StandardCharsets.UTF_8);
57+
Request req =
58+
new Request(exchange, body, Map.of("id", "x-1", "qty", 7), mapper, "op", Map.of());
59+
60+
Item item = req.asPojo(Item.class);
61+
62+
assertThat(item.id).isEqualTo("x-1");
63+
assertThat(item.qty).isEqualTo(7);
64+
}
65+
66+
@Test
67+
void asPojoFastPathWhenParsedAlreadyMatchesType() {
68+
HttpExchange exchange = mock(HttpExchange.class);
69+
Map<String, Object> alreadyParsed = Map.of("k", "v");
70+
Request req = new Request(exchange, "x".getBytes(), alreadyParsed, null, "op", Map.of());
71+
72+
Map<?, ?> result = req.asPojo(Map.class);
73+
assertThat(result).isSameAs(alreadyParsed);
74+
}
75+
76+
@Test
77+
void asPojoThrowsWhenBodyEmpty() {
78+
HttpExchange exchange = mock(HttpExchange.class);
79+
Request req = new Request(exchange, new byte[0], null, null, "op", Map.of());
80+
81+
assertThatThrownBy(() -> req.asPojo(Item.class))
82+
.isInstanceOf(IllegalStateException.class)
83+
.hasMessageContaining("no body");
84+
}
85+
86+
@Test
87+
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);
92+
TypeMapper plain =
93+
new TypeMapper() {
94+
@Override
95+
public Object readFrom(byte[] b, String h) {
96+
return new String(b, StandardCharsets.UTF_8);
97+
}
98+
99+
@Override
100+
public byte[] writeTo(Object v) {
101+
return v.toString().getBytes(StandardCharsets.UTF_8);
102+
}
103+
};
104+
Request req = new Request(exchange, "hello".getBytes(), "hello", plain, "op", Map.of());
105+
106+
assertThatThrownBy(() -> req.asPojo(Item.class))
107+
.isInstanceOf(IllegalStateException.class)
108+
.hasMessageContaining("TypedTypeMapper");
109+
}
110+
111+
static final class Item {
112+
public String id;
113+
public int qty;
114+
}
115+
44116
@Test
45117
void pathParamReturnsValueOrNull() {
46118
HttpExchange exchange = mock(HttpExchange.class);
47-
Request req = new Request(exchange, new byte[0], null, "op", Map.of("id", "42"));
119+
Request req = new Request(exchange, new byte[0], null, null, "op", Map.of("id", "42"));
48120

49121
assertThat(req.pathParam("id")).isEqualTo("42");
50122
assertThat(req.pathParam("missing")).isNull();
@@ -55,7 +127,7 @@ void exposesQueryParams() {
55127
HttpExchange exchange = mock(HttpExchange.class);
56128
when(exchange.getRequestURI())
57129
.thenReturn(URI.create("http://h/x?name=Alice%20Smith&active=true&active=false"));
58-
Request req = new Request(exchange, new byte[0], null, "op", Map.of());
130+
Request req = new Request(exchange, new byte[0], null, null, "op", Map.of());
59131

60132
assertThat(req.rawQuery()).isEqualTo("name=Alice%20Smith&active=true&active=false");
61133
assertThat(req.queryParam("name")).isEqualTo("Alice Smith");
@@ -70,7 +142,7 @@ void exposesQueryParams() {
70142
void queryParamsEmptyWhenNoQuery() {
71143
HttpExchange exchange = mock(HttpExchange.class);
72144
when(exchange.getRequestURI()).thenReturn(URI.create("http://h/x"));
73-
Request req = new Request(exchange, new byte[0], null, "op", Map.of());
145+
Request req = new Request(exchange, new byte[0], null, null, "op", Map.of());
74146

75147
assertThat(req.rawQuery()).isNull();
76148
assertThat(req.queryParams()).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, operationId, Map.of());
35+
Request req = new Request(exchange, new byte[0], null, null, operationId, Map.of());
3636
ScopedValue.where(DispatchHandler.CURRENT, req).call(body);
3737
}
3838

0 commit comments

Comments
 (0)