Chapter 17 of 18
17 — Real project: an HTTP server
The JDK's HttpServer and HttpClient — I/O at the edge, decisions in a testable core.
Kata: a tiny server that answers GET / and GET /notes and 404s everything else — over real HTTP, with no framework.
New ideas: the JDK’s built-in HttpServer and HttpClient, keeping I/O at the edge, and a test that needs no fixed port, no daemons, and no network.
This chapter is a kata — write the code yourself. If you’re following along inside the solutions repo, the finished classes also ship in
src/main/java/webserver— don’t open them yet. Write the Router first; the RED below catches a router that says “no” to everything.
Step 1 — RED: write the routing test first (no sockets)
The decision — “which response for which path?” — doesn’t need a server. Make it a plain
function and test that. The chapter’s RouterTest starts:
@Test
void servesTheRootGreeting() {
assertEquals(200, Router.route("/").status());
assertEquals("hello, tests", Router.route("/").body());
}
Run gradle test --tests "webserver.RouterTest":
error: cannot find symbol
assertEquals(200, Router.route("/").status());
^
symbol: class Router
RED #1 — Router doesn’t exist, and neither does the Response type it returns. The tests
declare the API: Router.route(path) → Response with .status() and .body().
Step 2 — the types the test demands
Response is a record with two factory methods:
public record Response(int status, String body) {
public static Response ok(String body) {
return new Response(200, body);
}
public static Response missing(String path) {
return new Response(404, path + " not found");
}
}
Step 3 — RED: the all-404 router
The naive implementation — “answer nothing, politely”:
public static Response route(String path) {
return Response.missing(path);
}
Run the tests:
RouterTest > answersUnknownPathsWith404() PASSED
RouterTest > servesTheRootGreeting() FAILED
org.opentest4j.AssertionFailedError: expected: <200> but was: <404>
The 404 test passes — and that’s the trap: one green test can make a broken router look finished. Adding the second route before returning the wrong answer is what ships bugs.
Step 4 — GREEN: the routes, as a switch
public static Response route(String path) {
return switch (path) {
case "/" -> Response.ok("hello, tests");
case "/notes" -> Response.ok("buy oat milk, learn Java");
default -> Response.missing(path);
};
}
Run gradle test --tests "webserver.RouterTest": GREEN — in milliseconds, with zero I/O.
Every behaviour rule in the whole server now lives in this one pure function.
Step 5 — the wiring (thin, and only here)
com.sun.net.httpserver.HttpServer ships with the JDK, in module jdk.httpserver:
server = HttpServer.create(new InetSocketAddress(port), 0);
server.createContext("/", this::handle);
The handler’s only job is translation — parse the path, ask Router, write the bytes:
private void handle(HttpExchange exchange) throws IOException {
Response response = Router.route(exchange.getRequestURI().getPath());
byte[] body = response.body().getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
exchange.sendResponseHeaders(response.status(), body.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
}
All decisions stayed in Router; the server is a thin adapter. That is the I/O-at-the-edge habit
from chapters 11–12 applied at process scale.
Step 6 — the honest test: over a real socket
One integration test starts the server on port 0 — “give me any free port” — then talks to it
with the JDK’s HttpClient:
NotesServer server = new NotesServer(0);
server.start();
try {
assertEquals("hello, tests", get(server, "/"));
assertEquals(404, status(server, "/missing"));
} finally {
server.stop();
}
port() returns the port the OS actually assigned, and the request goes to localhost. No fixed
port, no daemons, no mocking — the loop is closed: the same code a real caller would hit,
exercised in-process, on every gradle test. (If the server has no /notes route wired up, this
test is the one that catches it — its failing message: expected: <buy oat milk, learn Java> but was: </notes not found>.)
Java-specific notes
HttpServergives each request its own thread; on JDK 21 with virtual threads that concurrency is cheap.- Every rule lives in
RouterTest; the integration test proves the plumbing, not the logic — fast, stable, portable across machines. - A “real project” is mostly a stack of thin seams like this. Leaning on a pure testable core
(
Router) is what keeps code testable without Docker, mock HTTP layers, or a framework.
Run it
gradle test --tests "webserver.*"
Key takeaway: the JDK already has the web primitives — HttpServer to serve, HttpClient to
call. Build the decision logic as plain testable code, then wrap it in the thinnest possible I/O
layer. And remember the Step 3 trap: a single green test (404) didn’t mean the router worked.
Next: 18 — assertj — assertions that read like sentences.