Skip to content

Commit 33ad29f

Browse files
committed
feat: Add Request, ResponseBuilder, RequestHandler types
1 parent c263cda commit 33ad29f

5 files changed

Lines changed: 324 additions & 0 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package com.retailsvc.http;
2+
3+
import com.retailsvc.http.internal.DefaultResponseBuilder;
4+
import com.sun.net.httpserver.HttpExchange;
5+
import java.util.Map;
6+
7+
/**
8+
* The per-request handle passed to {@link RequestHandler}. Carries the parsed body, path
9+
* parameters, operation ID, and a fluent {@link ResponseBuilder} for writing the response.
10+
*/
11+
public final class Request {
12+
13+
private final HttpExchange exchange;
14+
private final byte[] body;
15+
private final Object parsed;
16+
private final String operationId;
17+
private final Map<String, String> pathParameters;
18+
private final Map<String, TypeMapper> bodyMappers;
19+
20+
public Request(
21+
HttpExchange exchange,
22+
byte[] body,
23+
Object parsed,
24+
String operationId,
25+
Map<String, String> pathParameters,
26+
Map<String, TypeMapper> bodyMappers) {
27+
this.exchange = exchange;
28+
this.body = body;
29+
this.parsed = parsed;
30+
this.operationId = operationId;
31+
this.pathParameters = pathParameters;
32+
this.bodyMappers = bodyMappers;
33+
}
34+
35+
public byte[] bytes() {
36+
return body;
37+
}
38+
39+
public Object parsed() {
40+
return parsed;
41+
}
42+
43+
public String operationId() {
44+
return operationId;
45+
}
46+
47+
public Map<String, String> pathParams() {
48+
return pathParameters;
49+
}
50+
51+
public String header(String name) {
52+
return exchange.getRequestHeaders().getFirst(name);
53+
}
54+
55+
public ResponseBuilder respond(int status) {
56+
return new DefaultResponseBuilder(exchange, status, bodyMappers);
57+
}
58+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.retailsvc.http;
2+
3+
import java.io.IOException;
4+
5+
/**
6+
* Handles a single request identified by OpenAPI {@code operationId}. Registered on {@link
7+
* OpenApiServer.Builder#handlers(java.util.Map)} by operation ID.
8+
*/
9+
@FunctionalInterface
10+
public interface RequestHandler {
11+
void handle(Request request) throws IOException;
12+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.retailsvc.http;
2+
3+
import java.io.IOException;
4+
import java.io.OutputStream;
5+
6+
/**
7+
* Fluent response builder returned by {@link Request#respond(int)}. Each {@code Request} permits
8+
* exactly one terminal call ({@link #empty()}, {@link #bytes(byte[])}, {@link #text(String)},
9+
* {@link #json(Object)}, {@link #body(String, Object)}, {@link #stream()}, or {@link
10+
* #stream(long)}); calling any of them after the first throws {@link IllegalStateException}. {@link
11+
* #header(String, String)} / {@link #contentType(String)} must be called before the terminal.
12+
*
13+
* <p>Note: a {@code problem(...)} terminal is deferred — no public {@code ProblemDetail} type
14+
* exists yet; only the internal {@code ProblemDetailRenderer} is available.
15+
*/
16+
public interface ResponseBuilder {
17+
18+
ResponseBuilder header(String name, String value);
19+
20+
ResponseBuilder contentType(String contentType);
21+
22+
void empty() throws IOException;
23+
24+
void bytes(byte[] body) throws IOException;
25+
26+
void text(String body) throws IOException;
27+
28+
void json(Object body) throws IOException;
29+
30+
void body(String mediaType, Object body) throws IOException;
31+
32+
OutputStream stream() throws IOException;
33+
34+
OutputStream stream(long length) throws IOException;
35+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package com.retailsvc.http.internal;
2+
3+
import com.retailsvc.http.ResponseBuilder;
4+
import com.retailsvc.http.TypeMapper;
5+
import com.sun.net.httpserver.HttpExchange;
6+
import java.io.IOException;
7+
import java.io.OutputStream;
8+
import java.nio.charset.StandardCharsets;
9+
import java.util.LinkedHashMap;
10+
import java.util.Map;
11+
12+
public final class DefaultResponseBuilder implements ResponseBuilder {
13+
14+
private static final String CONTENT_TYPE = "Content-Type";
15+
16+
private final HttpExchange exchange;
17+
private final int status;
18+
private final Map<String, TypeMapper> mappers;
19+
private final Map<String, String> pendingHeaders = new LinkedHashMap<>();
20+
private boolean terminated;
21+
22+
public DefaultResponseBuilder(
23+
HttpExchange exchange, int status, Map<String, TypeMapper> mappers) {
24+
this.exchange = exchange;
25+
this.status = status;
26+
this.mappers = mappers;
27+
}
28+
29+
@Override
30+
public ResponseBuilder header(String name, String value) {
31+
checkNotTerminated();
32+
pendingHeaders.put(name, value);
33+
return this;
34+
}
35+
36+
@Override
37+
public ResponseBuilder contentType(String contentType) {
38+
return header(CONTENT_TYPE, contentType);
39+
}
40+
41+
@Override
42+
public void empty() throws IOException {
43+
terminate();
44+
applyHeaders();
45+
exchange.sendResponseHeaders(status, -1);
46+
}
47+
48+
@Override
49+
public void bytes(byte[] body) throws IOException {
50+
terminate();
51+
applyHeaders();
52+
exchange.sendResponseHeaders(status, body.length);
53+
if (body.length > 0) {
54+
try (OutputStream out = exchange.getResponseBody()) {
55+
out.write(body);
56+
}
57+
}
58+
}
59+
60+
@Override
61+
public void text(String body) throws IOException {
62+
pendingHeaders.putIfAbsent(CONTENT_TYPE, "text/plain; charset=UTF-8");
63+
bytes(body.getBytes(StandardCharsets.UTF_8));
64+
}
65+
66+
@Override
67+
public void json(Object body) throws IOException {
68+
this.body("application/json", body);
69+
}
70+
71+
@Override
72+
public void body(String mediaType, Object value) throws IOException {
73+
TypeMapper mapper = mappers.get(mediaType.toLowerCase(java.util.Locale.ROOT));
74+
if (mapper == null) {
75+
throw new IllegalStateException("No TypeMapper registered for " + mediaType);
76+
}
77+
pendingHeaders.putIfAbsent(CONTENT_TYPE, mediaType);
78+
bytes(mapper.writeTo(value));
79+
}
80+
81+
@Override
82+
public OutputStream stream() throws IOException {
83+
terminate();
84+
applyHeaders();
85+
exchange.sendResponseHeaders(status, 0);
86+
return exchange.getResponseBody();
87+
}
88+
89+
@Override
90+
public OutputStream stream(long length) throws IOException {
91+
if (length < 0) {
92+
throw new IllegalArgumentException("length must be non-negative");
93+
}
94+
terminate();
95+
applyHeaders();
96+
exchange.sendResponseHeaders(status, length);
97+
return exchange.getResponseBody();
98+
}
99+
100+
private void terminate() {
101+
checkNotTerminated();
102+
terminated = true;
103+
}
104+
105+
private void checkNotTerminated() {
106+
if (terminated) {
107+
throw new IllegalStateException("Response already sent");
108+
}
109+
}
110+
111+
private void applyHeaders() {
112+
pendingHeaders.forEach(exchange.getResponseHeaders()::add);
113+
}
114+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package com.retailsvc.http;
2+
3+
import static java.net.http.HttpClient.Version.HTTP_1_1;
4+
import static java.net.http.HttpResponse.BodyHandlers.ofString;
5+
import static java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor;
6+
import static org.assertj.core.api.Assertions.assertThat;
7+
8+
import java.net.URI;
9+
import java.net.http.HttpClient;
10+
import java.net.http.HttpRequest;
11+
import java.net.http.HttpRequest.BodyPublishers;
12+
import java.util.Map;
13+
import org.junit.jupiter.api.Disabled;
14+
import org.junit.jupiter.api.Test;
15+
16+
// TODO Task 9: remove @Disabled once Builder.handlers() accepts Map<String, RequestHandler>
17+
@Disabled("Enabled in Task 9 once handlers() takes RequestHandler")
18+
class RequestResponseGatewayTest extends ServerBaseTest {
19+
20+
/**
21+
* Casts a {@code Map<String, RequestHandler>} to the raw {@code Map} type so callers compile
22+
* against the existing {@code Builder.handlers(Map<String, HttpHandler>)} signature. This cast is
23+
* safe to write here because the class is {@link Disabled} — it never runs until Task 9 replaces
24+
* the stub with a real {@code handlers(Map<String, RequestHandler>)} overload.
25+
*/
26+
@SuppressWarnings("unchecked")
27+
private static Map asRawHandlers(Map<String, RequestHandler> handlers) {
28+
return handlers;
29+
}
30+
31+
@Test
32+
void respondJsonWritesBodyAndContentType() throws Exception {
33+
RequestHandler echo = req -> req.respond(200).json(Map.of("op", req.operationId()));
34+
server =
35+
OpenApiServer.builder()
36+
.spec(spec)
37+
.handlers(asRawHandlers(Map.of("getRoot", echo, "postData", echo)))
38+
.port(0)
39+
.build();
40+
HttpClient client =
41+
HttpClient.newBuilder()
42+
.executor(newVirtualThreadPerTaskExecutor())
43+
.version(HTTP_1_1)
44+
.build();
45+
var resp =
46+
client.send(
47+
HttpRequest.newBuilder()
48+
.uri(URI.create("http://localhost:%d/api/v1/data".formatted(server.listenPort())))
49+
.header("Content-Type", "application/json")
50+
.POST(BodyPublishers.ofString("{\"n\":1}"))
51+
.build(),
52+
ofString());
53+
assertThat(resp.statusCode()).isEqualTo(200);
54+
assertThat(resp.headers().firstValue("Content-Type")).contains("application/json");
55+
assertThat(resp.body()).contains("\"op\":\"postData\"");
56+
}
57+
58+
@Test
59+
void respondEmptyUses204Style() throws Exception {
60+
RequestHandler ok = req -> req.respond(204).empty();
61+
server =
62+
OpenApiServer.builder()
63+
.spec(spec)
64+
.handlers(asRawHandlers(Map.of("getRoot", ok, "postData", ok)))
65+
.port(0)
66+
.build();
67+
var resp =
68+
HttpClient.newHttpClient()
69+
.send(
70+
HttpRequest.newBuilder()
71+
.uri(URI.create("http://localhost:%d/api/v1/".formatted(server.listenPort())))
72+
.GET()
73+
.build(),
74+
ofString());
75+
assertThat(resp.statusCode()).isEqualTo(204);
76+
assertThat(resp.body()).isEmpty();
77+
}
78+
79+
@Test
80+
void respondStreamUsesChunkedEncoding() throws Exception {
81+
RequestHandler streamer =
82+
req -> {
83+
try (var out = req.respond(200).contentType("text/plain").stream()) {
84+
out.write("hello ".getBytes());
85+
out.write("world".getBytes());
86+
}
87+
};
88+
server =
89+
OpenApiServer.builder()
90+
.spec(spec)
91+
.handlers(asRawHandlers(Map.of("getRoot", streamer, "postData", streamer)))
92+
.port(0)
93+
.build();
94+
var resp =
95+
HttpClient.newHttpClient()
96+
.send(
97+
HttpRequest.newBuilder()
98+
.uri(URI.create("http://localhost:%d/api/v1/".formatted(server.listenPort())))
99+
.GET()
100+
.build(),
101+
ofString());
102+
assertThat(resp.statusCode()).isEqualTo(200);
103+
assertThat(resp.body()).isEqualTo("hello world");
104+
}
105+
}

0 commit comments

Comments
 (0)