CRUD Handlers

Wire create, read, update and delete to HTTP with the right status codes, a Location header and empty 204 replies, using only the JDK server.

Syntaxexchange.getResponseHeaders().set("Location", "/tasks/" + id); exchange.sendResponseHeaders(201, bytes.length); // body follows exchange.sendResponseHeaders(204, -1); // no body at all

CRUD handlers are the glue between HTTP and the repository. Each one does the same four steps: pull values out of the request (path id, body), call the repository, choose a status code, and write the response. Keep business rules out of them; a handler that grows past a screenful usually hides a service class.

What each operation returns

  • Create (POST /tasks): 201 Created, a Location header with the new URL, and usually the created resource so the client sees its id and defaults.
  • Read (GET /tasks/1): 200 with the body, or 404 when the id is unknown.
  • Replace (PUT /tasks/1): 200 with the new state, or 404. Some APIs create on PUT when the client chooses ids; decide once and document it.
  • Delete (DELETE /tasks/1): 204 No Content, or 404.

Details of com.sun.net.httpserver

sendResponseHeaders(status, length) takes the body length in bytes. Pass -1 when there is no body, which is what 204 requires; 0 means the length is unknown and the body is sent in chunks, so an empty 404 sent with 0 goes out as a chunked reply. Headers must be set before that call. Always read or close the request body, and always close the exchange (closing the response stream does it); an exchange left open ties up the connection. Note also that an exception thrown from a handler is not turned into a 500: the connection is closed and the client sees an I/O error, so catch at the edge.

Bytes, not characters

The length is in bytes, so encode the body first with getBytes(StandardCharsets.UTF_8) and use that array's length. Using body.length() breaks as soon as a title contains a non-ASCII character such as é.

JSON in these examples

The examples read flat JSON with a regular expression and build output by concatenation to stay JDK-only. That is acceptable for a demo with trusted, simple strings; in a real service use a JSON library, which handles escaping, nesting and numbers properly.

Design the handler so the HTTP layer is thin. When the logic is a function from a request record to a response record, you can unit test every status code without starting a server, as the first example below shows.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Return 201 with a Location header so a front end can navigate straight to the order it just created.
  • Answer DELETE /sessions/{id} with 204 and no body so logout clients do not try to parse JSON.
  • Protect a shared document endpoint from lost updates when two people edit at once.

More examples

Handlers as plain functions you can test

Separating the decision (which status, which body) from the transport means every branch can be tested in milliseconds. It also shows DELETE's idempotency: the second call leaves the same state but reports 404.

Example · java
record Request(String method, String path, String body) {}
record Response(int status, String body) {}
record Task(long id, String title) {}

// No HTTP types in here: a thin adapter turns an HttpExchange into a Request.
class TaskApi {
    private final Map<Long, Task> tasks = new TreeMap<>();
    private long nextId = 1;

    Response handle(Request req) {
        String[] parts = req.path().split("/");
        if (parts.length == 2) {
            return switch (req.method()) {
                case "GET" -> new Response(200, tasks.values().toString());
                case "POST" -> {
                    Task task = new Task(nextId++, req.body());
                    tasks.put(task.id(), task);
                    yield new Response(201, task.toString());
                }
                default -> new Response(405, "");
            };
        }
        long id = Long.parseLong(parts[2]);
        return switch (req.method()) {
            case "GET" -> tasks.containsKey(id) ? new Response(200, tasks.get(id).toString()) : new Response(404, "");
            case "DELETE" -> tasks.remove(id) != null ? new Response(204, "") : new Response(404, "");
            default -> new Response(405, "");
        };
    }
}

void main() {
    TaskApi api = new TaskApi();
    List<Request> requests = List.of(
        new Request("POST", "/tasks", "Buy milk"),
        new Request("GET", "/tasks/1", ""),
        new Request("DELETE", "/tasks/1", ""),
        new Request("DELETE", "/tasks/1", ""),
        new Request("PATCH", "/tasks", ""));
    for (Request r : requests) {
        IO.println(r.method() + " " + r.path() + " -> " + api.handle(r));
    }
}

// Output:
// POST /tasks -> Response[status=201, body=Task[id=1, title=Buy milk]]
// GET /tasks/1 -> Response[status=200, body=Task[id=1, title=Buy milk]]
// DELETE /tasks/1 -> Response[status=204, body=]
// DELETE /tasks/1 -> Response[status=404, body=]
// PATCH /tasks -> Response[status=405, body=]

PUT replaces, PATCH merges

Clients that send partial bodies to a PUT endpoint silently wipe fields, which is one of the most common CRUD bugs. If you implement JSON Merge Patch (RFC 7396), an explicit null in the body means remove the field, so you must tell absent from null when parsing.

Example · java
record Profile(String name, String city, String phone) {}

// PUT: the body is the whole new resource; anything missing is gone.
Profile put(Map<String, String> body) {
    return new Profile(body.get("name"), body.get("city"), body.get("phone"));
}

// PATCH: only the fields that were sent change.
Profile patch(Profile current, Map<String, String> body) {
    return new Profile(
        body.getOrDefault("name", current.name()),
        body.getOrDefault("city", current.city()),
        body.getOrDefault("phone", current.phone()));
}

void main() {
    Profile stored = new Profile("Ada", "London", "+44 20 7946 0000");
    Map<String, String> body = Map.of("city", "Leeds");
    IO.println("PUT   -> " + put(body));
    IO.println("PATCH -> " + patch(stored, body));
}

// Output:
// PUT   -> Profile[name=null, city=Leeds, phone=null]
// PATCH -> Profile[name=Ada, city=Leeds, phone=+44 20 7946 0000]

Refusing lost updates with ETag and If-Match

Without the check, Bob's PUT would silently overwrite Alice's change. 412 tells Bob to fetch the current version and try again, and 428 forces clients to send the header at all.

Example · java
record Doc(long id, String text, int version) {
    String etag() { return "\"" + version + "\""; }
}

Doc doc = new Doc(7, "first draft", 1);

int put(String ifMatch, String text) {
    if (ifMatch == null) return 428;                // Precondition Required
    if (!ifMatch.equals(doc.etag())) return 412;    // Precondition Failed
    doc = new Doc(doc.id(), text, doc.version() + 1);
    return 200;
}

void main() {
    String seenByAlice = doc.etag();
    String seenByBob = doc.etag();
    IO.println("GET -> ETag " + seenByAlice);
    IO.println("Alice PUT If-Match " + seenByAlice + " -> " + put(seenByAlice, "Alice's edit") + ", ETag now " + doc.etag());
    IO.println("Bob   PUT If-Match " + seenByBob + " -> " + put(seenByBob, "Bob's edit"));
    IO.println("PUT without If-Match -> " + put(null, "blind write"));
    IO.println("stored: " + doc.text());
}

// Output:
// GET -> ETag "1"
// Alice PUT If-Match "1" -> 200, ETag now "2"
// Bob   PUT If-Match "1" -> 412
// PUT without If-Match -> 428
// stored: Alice's edit

Discussion

  • Be the first to comment on this lesson.