1 The Problem
We want a REST API: a web service that lets clients create, read, update, and delete records (a to-do list, say) using standard HTTP methods (GET, POST, PUT, DELETE) and JSON. It teaches the conventions of REST — how the entire web of apps and services talks to itself.
2 How to Think About It
Separate the decision from the delivery. A pure function decides what any given request should get back; a thin adapter is the only part that actually touches a socket.
3 The Build — explained part by part
Here is the complete API. handle(store, method, path, body) is the whole routing table as one pure function — every test below calls it directly, with zero networking involved.
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
/**
* REST API: an in-memory to-do list served over real HTTP with no external
* framework, using the JDK's own com.sun.net.httpserver.
*/
public class RestApi {
record Todo(int id, String text, boolean done) {}
/** A pure, framework-free routing function: request in, response out. Directly testable. */
record Response(int status, String contentType, String body) {}
static class Store {
private final Map<Integer, Todo> todos = new LinkedHashMap<>();
private final AtomicInteger nextId = new AtomicInteger(1);
synchronized Todo add(String text) {
int id = nextId.getAndIncrement();
Todo t = new Todo(id, text, false);
todos.put(id, t);
return t;
}
synchronized Map<Integer, Todo> all() {
return new LinkedHashMap<>(todos);
}
synchronized boolean delete(int id) {
return todos.remove(id) != null;
}
}
static String todoToJson(Todo t) {
return String.format("{\"id\":%d,\"text\":%s,\"done\":%b}", t.id(), quote(t.text()), t.done());
}
static String quote(String s) {
return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
}
/** Pulls "text" out of a tiny, single-field JSON body without a JSON library. */
static String extractTextField(String jsonBody) {
int i = jsonBody.indexOf("\"text\"");
if (i < 0) return "";
int colon = jsonBody.indexOf(':', i);
int firstQuote = jsonBody.indexOf('"', colon + 1);
int secondQuote = jsonBody.indexOf('"', firstQuote + 1);
if (firstQuote < 0 || secondQuote < 0) return "";
return jsonBody.substring(firstQuote + 1, secondQuote);
}
/** The whole routing table, as a pure function of method+path+body — no I/O, fully testable. */
static Response handle(Store store, String method, String path, String body) {
if (method.equals("GET") && path.equals("/todos")) {
StringBuilder sb = new StringBuilder("[");
boolean first = true;
for (Todo t : store.all().values()) {
if (!first) sb.append(",");
sb.append(todoToJson(t));
first = false;
}
sb.append("]");
return new Response(200, "application/json", sb.toString());
}
if (method.equals("POST") && path.equals("/todos")) {
String text = extractTextField(body);
if (text.isBlank()) {
return new Response(400, "application/json", "{\"error\":\"text is required\"}");
}
Todo created = store.add(text);
return new Response(201, "application/json", todoToJson(created));
}
if (method.equals("DELETE") && path.startsWith("/todos/")) {
try {
int id = Integer.parseInt(path.substring("/todos/".length()));
boolean removed = store.delete(id);
return removed
? new Response(204, "application/json", "")
: new Response(404, "application/json", "{\"error\":\"not found\"}");
} catch (NumberFormatException e) {
return new Response(400, "application/json", "{\"error\":\"invalid id\"}");
}
}
return new Response(404, "application/json", "{\"error\":\"no such route\"}");
}
static HttpServer start(int port) throws IOException {
Store store = new Store();
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
HttpHandler handler = exchange -> {
byte[] rawBody = exchange.getRequestBody().readAllBytes();
String body = new String(rawBody, StandardCharsets.UTF_8);
String method = exchange.getRequestMethod();
String path = exchange.getRequestURI().getPath();
Response resp = handle(store, method, path, body);
byte[] bytes = resp.body().getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", resp.contentType());
exchange.sendResponseHeaders(resp.status(), bytes.length == 0 ? -1 : bytes.length);
if (bytes.length > 0) exchange.getResponseBody().write(bytes);
exchange.close();
};
server.createContext("/todos", handler);
server.setExecutor(null);
server.start();
return server;
}
public static void main(String[] args) throws IOException {
int port = 8080;
start(port);
System.out.println("Listening on http://localhost:" + port + "/todos");
}
}
record Response(int status, String contentType, String body) and static Response handle(Store store, String method, String path, String body) — the entire routing table lives in one pure function with no
HttpExchange in sight. The HttpHandler lambda passed to createContext is a thin adapter that only extracts inputs and writes outputs — every actual decision happens in handle, which is why it can be tested without ever starting a server.synchronized on every
Store method, and AtomicInteger for the ID counter — the JDK’s HTTP server can dispatch concurrent requests to the same handler, so shared mutable state needs real protection, not just correct-looking single-threaded code.extractTextField hand-parses one field out of a tiny JSON body — genuinely fragile compared to a real JSON library, and flagged as such below; good enough for a single known field, wrong for anything with nested objects or arrays.
HttpHandler lambda — that makes the actual logic untestable without starting a real server and making real HTTP calls for every test.handle, and let the handler be a thin, mostly-untested adapter around it.HashMap for the to-do store — concurrent requests can corrupt it or throw ConcurrentModificationException under real traffic.synchronized as Store does.4 Test & Prove Each Part
We test the pure routing function directly for every route and status code — no server, no sockets, no HTTP involved in most of these.
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class RestApiTest {
@Test
public void extractsTextFieldFromSimpleJson() {
assertEquals("buy milk", RestApi.extractTextField("{\"text\":\"buy milk\"}"));
}
@Test
public void extractMissingFieldReturnsEmptyInsteadOfThrowing() {
assertEquals("", RestApi.extractTextField("{}"));
}
@Test
public void postingATodoAddsItAndReturns201() {
RestApi.Store store = new RestApi.Store();
var resp = RestApi.handle(store, "POST", "/todos", "{\"text\":\"milk\"}");
assertEquals(201, resp.status());
assertTrue(resp.body().contains("\"text\":\"milk\""));
assertEquals(1, store.all().size());
}
@Test
public void postingWithoutTextReturns400() {
RestApi.Store store = new RestApi.Store();
var resp = RestApi.handle(store, "POST", "/todos", "{}");
assertEquals(400, resp.status());
assertEquals(0, store.all().size());
}
@Test
public void gettingReturnsEveryStoredTodoAsJsonArray() {
RestApi.Store store = new RestApi.Store();
store.add("a");
store.add("b");
var resp = RestApi.handle(store, "GET", "/todos", "");
assertEquals(200, resp.status());
assertTrue(resp.body().startsWith("["));
assertTrue(resp.body().contains("\"a\""));
assertTrue(resp.body().contains("\"b\""));
}
@Test
public void deletingAnExistingTodoReturns204AndRemovesIt() {
RestApi.Store store = new RestApi.Store();
var created = store.add("milk");
var resp = RestApi.handle(store, "DELETE", "/todos/" + created.id(), "");
assertEquals(204, resp.status());
assertEquals(0, store.all().size());
}
@Test
public void deletingAMissingTodoReturns404WithoutChangingTheStore() {
RestApi.Store store = new RestApi.Store();
store.add("milk");
var resp = RestApi.handle(store, "DELETE", "/todos/999", "");
assertEquals(404, resp.status());
assertEquals(1, store.all().size());
}
@Test
public void unknownRouteReturns404() {
RestApi.Store store = new RestApi.Store();
var resp = RestApi.handle(store, "GET", "/nope", "");
assertEquals(404, resp.status());
}
}
Compile and run with javac -cp junit-4.13.2.jar and hamcrest-core-1.3.jar RestApi.java RestApiTest.java then java -cp .:junit-4.13.2.jar:hamcrest-core-1.3.jar org.junit.runner.JUnitCore RestApiTest. Every one of these 8 tests calls RestApi.handle(...) directly with a fresh in-memory Store — none of them start the actual HTTP server, which is exactly the point of keeping the routing logic pure.
5 The Interface
A REST API's interface is its routes. Here are all three, documented the way real API docs would.
What it expects
curl -X POST localhost:8080/todos -d '{"text":"buy milk"}'
curl localhost:8080/todos
curl -X DELETE localhost:8080/todos/1What it returns
201 {"id":1,"text":"buy milk","done":false}
200 [{"id":1,...}]
204 (empty)
404 {"error":"not found"}6 Run It & Automate It
Save the code as RestApi.java and compile it with javac — that turns your source into .class bytecode files, which java then runs on the JVM. No separate install step: any real JDK ships both tools.
javac RestApi.java && java RestApiThen hit it from another terminal with curl, exactly as shown below.
A CI tool like Jenkins runs the same compile-then-test steps automatically whenever the code changes — every line below has a plain explanation.
$ java RestApi
Listening on http://localhost:8080/todos
$ curl -X POST http://localhost:8080/todos -d '{"text":"buy milk"}'
{"id":1,"text":"buy milk","done":false}
$ curl http://localhost:8080/todos
[{"id":1,"text":"buy milk","done":false}]
$ curl -X DELETE http://localhost:8080/todos/1 -o /dev/null -w "%{http_code}\n"
204main.curl — server.start() is non-blocking, so a script that starts the server and immediately curls it can race ahead of it.// Jenkinsfile — runs the tests automatically every time the code changes.
pipeline {
agent any // run on any available machine
environment {
CP = 'junit-4.13.2.jar:hamcrest-core-1.3.jar' // JUnit + its one dependency
}
stages {
stage('Get the code') {
steps { checkout scm } // download the latest code
}
stage('Set up JDK') {
steps {
sh 'java -version' // confirm a JDK is installed
sh 'javac -cp "$CP" *.java' // compile the program and its tests together
}
}
stage('Run the tests') {
steps {
sh 'java -cp ".:$CP" org.junit.runner.JUnitCore RestApiTest'
}
}
}
post {
success { echo 'All tests passed.' }
failure { echo 'A test failed — look above.' }
}
}
- Add a PATCH route. Mark a to-do done without deleting and recreating it. (Teaches: extending the routing function with a new method/path combination.)
- Persist to a file. Combine this with the to-do list project’s line-based format so data survives a restart. (Teaches: connecting an in-memory API to real persistence.)
- Add a real JSON library. If you have network access, replace the hand-written JSON with one, and see how much of
todoToJson/extractTextFielddisappears. (Teaches: what a JSON library actually saves you from writing.)