← thecodex.expert · The Codex Family of Knowledge
Tier 3 · Upper-Intermediate · Java Project

REST API

A real HTTP JSON API for a to-do list, built entirely on the JDK's own com.sun.net.httpserver with no external framework. Teaches a pure routing function, thread-safe shared state, and testing an API without starting a server.

🧠 Teaches how to think spoonfed, every age Last verified:

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.

Where this shows up: every mobile app backend, every single-page web app, every microservice, every public API (Stripe, Twitter, GitHub). REST is the lingua franca of the internet. Understanding it is essential to backend work.

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.

The plan — in plain English
1. Store to-dos in a thread-safe in-memory map. → 2. Route method+path+body to a decision, as a pure function with no I/O. → 3. Adapt that decision to real HTTP inside the server's handler. → 4. Test the routing function directly, and the whole server for real, separately.

GET

POST

PUT

DELETE

Request arrives

Which method?

Return records as JSON

Create a record

Update a record

Remove a record

Send JSON response

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.

JavaRestApi.java
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");
    }
}
⚠ No in-browser playground here
Java compiles to JVM bytecode and needs a real JDK to run, so unlike the Python version of this project there is no editor above you can run in the browser. Copy the code below and run it on your own machine — it takes seconds once a JDK is installed.
What each part does — in plain words
com.sun.net.httpserver.HttpServer — a real, if minimal, HTTP server bundled with every JDK, needing no Spring Boot or other framework. It is intentionally low-level: no automatic JSON binding, no routing DSL, which is exactly what makes it a good teaching tool for what those frameworks actually automate.

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.
Common mistakes — and how to avoid them
✗ Putting routing decisions directly inside the HttpHandler lambda — that makes the actual logic untestable without starting a real server and making real HTTP calls for every test.
✓ Pull the decision into a pure function like handle, and let the handler be a thin, mostly-untested adapter around it.
✗ Using a plain HashMap for the to-do store — concurrent requests can corrupt it or throw ConcurrentModificationException under real traffic.
✓ Either use a genuinely thread-safe collection, or protect access with 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.

Posting a valid to-do adds it and returns 201 with the created JSON
Posting without a text field returns 400 and adds nothing
GET returns every stored to-do as a JSON array
Deleting an existing to-do returns 204 and actually removes it
Deleting a missing to-do returns 404 without touching the store
An unrecognized route returns 404
JavaRestApiTest.java
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.

INPUTROUTESGET /todos, POST /todos, DELETE /todos/:id
What it expects
curl -X POST localhost:8080/todos -d '{"text":"buy milk"}'
curl localhost:8080/todos
curl -X DELETE localhost:8080/todos/1
OUTPUTRESPONSESJSON, with a real status code per outcome
What 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.

Run it locally
javac RestApi.java && java RestApi
Then 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.

What you should see when it works
Terminala real run
$ 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"
204
If it breaks — how to fix it
🚨 java.net.BindException: Address already in use
Port 8080 is already taken by another process, possibly a previous run of this same server you forgot to stop. Kill it, or change the port in main.
🚨 curl gets no response, or hangs.
Check the server actually printed "Listening on..." before you ran curl — server.start() is non-blocking, so a script that starts the server and immediately curls it can race ahead of it.
GroovyJenkinsfile
// 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.' }
    }
}
🎯 Try this next — make it yours
  1. 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.)
  2. 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.)
  3. Add a real JSON library. If you have network access, replace the hand-written JSON with one, and see how much of todoToJson/extractTextField disappears. (Teaches: what a JSON library actually saves you from writing.)
What you learned
You learned to keep routing logic as a pure, framework-free function separate from the HTTP adapter around it, why shared mutable state needs real synchronization once concurrent requests are possible, and what a JSON library automates that hand-written parsing does the hard way. Related: Concurrency, Standard Library.