AdvancedJava · Lesson 4 of 9

HTTP: HttpClient & a Built-in Server

Serve a small JSON API with the JDK's HttpServer and call it with java.net.http.HttpClient.

The JDK includes everything for basic HTTP: java.net.http.HttpClient makes requests (synchronous or async, HTTP/2, timeouts), and com.sun.net.httpserver.HttpServer serves them. They're perfect for tools, tests and small services — and for understanding what frameworks such as Spring do for you.

Each handler receives an HttpExchange: read the method, path and body, then send a status code, headers and response body. Always set Content-Type and the correct status codes.

Give the client a connectTimeout and each request a timeout, and check response.statusCode() before trusting the body. In real projects, use a JSON library such as Jackson instead of building JSON strings by hand.

HttpDemo.javaJava
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;

public class HttpDemo {
    static final Map<String, Integer> SCORES = new ConcurrentHashMap<>(Map.of("amina", 88, "juma", 42));

    static void send(HttpExchange ex, int status, String json) throws IOException {
        byte[] body = json.getBytes(StandardCharsets.UTF_8);
        ex.getResponseHeaders().set("Content-Type", "application/json");
        ex.sendResponseHeaders(status, body.length);
        try (var out = ex.getResponseBody()) { out.write(body); }
    }

    static void handleScores(HttpExchange ex) throws IOException {
        String name = ex.getRequestURI().getPath().replaceFirst("^/scores/?", "").toLowerCase();
        switch (ex.getRequestMethod()) {
            case "GET" -> {
                Integer score = SCORES.get(name);
                if (score == null) send(ex, 404, "{\"error\":\"not found\"}");
                else send(ex, 200, "{\"student\":\"%s\",\"score\":%d}".formatted(name, score));
            }
            case "PUT" -> {
                String body = new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8).trim();
                try {
                    int score = Integer.parseInt(body);
                    if (score < 0 || score > 100) throw new NumberFormatException();
                    SCORES.put(name, score);
                    send(ex, 200, "{\"saved\":%d}".formatted(score));
                } catch (NumberFormatException e) {
                    send(ex, 400, "{\"error\":\"score must be 0-100\"}");
                }
            }
            default -> send(ex, 405, "{\"error\":\"method not allowed\"}");
        }
    }

    public static void main(String[] args) throws Exception {
        HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 8080), 0);
        server.createContext("/scores", ex -> {
            try { handleScores(ex); } finally { ex.close(); }
        });
        server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
        server.start();
        System.out.println("Listening on http://localhost:8080/scores/{name}");

        HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();
        String base = "http://localhost:8080/scores/";

        for (var req : new HttpRequest[]{
            HttpRequest.newBuilder(URI.create(base + "amina")).GET().build(),
            HttpRequest.newBuilder(URI.create(base + "neema")).PUT(HttpRequest.BodyPublishers.ofString("91")).build(),
            HttpRequest.newBuilder(URI.create(base + "neema")).GET().build(),
            HttpRequest.newBuilder(URI.create(base + "juma")).PUT(HttpRequest.BodyPublishers.ofString("150")).build(),
            HttpRequest.newBuilder(URI.create(base + "ali")).GET().build(),
        }) {
            HttpResponse<String> res = client.send(
                HttpRequest.newBuilder(req, (n, v) -> true).timeout(Duration.ofSeconds(5)).build(),
                HttpResponse.BodyHandlers.ofString());
            System.out.println(req.method() + " " + req.uri().getPath() + " -> " + res.statusCode() + " " + res.body());
        }

        server.stop(0);
    }
}

Key points

  • HttpClient (with timeouts) and HttpServer ship with the JDK.
  • Set Content-Type, return accurate status codes and validate request bodies.
  • Use a JSON library (Jackson) in real projects instead of hand-built strings.

Exercise

Add GET /scores that returns all scores as a JSON object, and DELETE /scores/{name}. Then make the client send the five requests concurrently with client.sendAsync and CompletableFuture.allOf.

Show solution

Try the exercise yourself first — then compare your approach with this one.

The handler now distinguishes /scores (the collection) from /scores/{name}. GET /scores builds a JSON object from the map, and DELETE removes a student. On the client side, sendAsync starts five independent requests at once and CompletableFuture.allOf waits for them together. The full list is fetched afterwards: run alongside the DELETE, its result would depend on which request the server handled first.

HttpDemo2.javaJava
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;

public class HttpDemo2 {
    static final Map<String, Integer> SCORES = new ConcurrentHashMap<>(Map.of("amina", 88, "juma", 42, "neema", 71));

    static void send(HttpExchange ex, int status, String json) throws IOException {
        byte[] body = json.getBytes(StandardCharsets.UTF_8);
        ex.getResponseHeaders().set("Content-Type", "application/json");
        ex.sendResponseHeaders(status, status == 204 ? -1 : body.length);
        if (status != 204) try (var out = ex.getResponseBody()) { out.write(body); }
    }

    static void handle(HttpExchange ex) throws IOException {
        String name = ex.getRequestURI().getPath().replaceFirst("^/scores/?", "").toLowerCase();
        String method = ex.getRequestMethod();

        if (name.isEmpty() && method.equals("GET")) {
            String json = new TreeMap<>(SCORES).entrySet().stream()
                .map(e -> "\"%s\":%d".formatted(e.getKey(), e.getValue()))
                .collect(Collectors.joining(",", "{", "}"));
            send(ex, 200, json);
        } else if (method.equals("GET")) {
            Integer score = SCORES.get(name);
            if (score == null) send(ex, 404, "{\"error\":\"not found\"}");
            else send(ex, 200, "{\"student\":\"%s\",\"score\":%d}".formatted(name, score));
        } else if (method.equals("DELETE")) {
            if (SCORES.remove(name) == null) send(ex, 404, "{\"error\":\"not found\"}");
            else send(ex, 204, "");
        } else {
            send(ex, 405, "{\"error\":\"method not allowed\"}");
        }
    }

    public static void main(String[] args) throws Exception {
        HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 8080), 0);
        server.createContext("/scores", ex -> { try { handle(ex); } finally { ex.close(); } });
        server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
        server.start();

        HttpClient client = HttpClient.newHttpClient();
        String base = "http://localhost:8080/scores";
        List<HttpRequest> requests = List.of(                      // independent requests, safe to run together
            HttpRequest.newBuilder(URI.create(base + "/amina")).GET().build(),
            HttpRequest.newBuilder(URI.create(base + "/neema")).GET().build(),
            HttpRequest.newBuilder(URI.create(base + "/ali")).GET().build(),
            HttpRequest.newBuilder(URI.create(base + "/juma")).DELETE().build(),
            HttpRequest.newBuilder(URI.create(base + "/nobody")).DELETE().build());

        List<CompletableFuture<HttpResponse<String>>> futures = requests.stream()
            .map(r -> client.sendAsync(r, HttpResponse.BodyHandlers.ofString()))
            .toList();
        CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).join();   // all five in flight together

        for (int i = 0; i < requests.size(); i++) {
            HttpResponse<String> res = futures.get(i).join();
            System.out.println(requests.get(i).method() + " " + requests.get(i).uri().getPath() + " -> " + res.statusCode() + " " + res.body());
        }
        HttpResponse<String> all = client.send(HttpRequest.newBuilder(URI.create(base)).GET().build(),
            HttpResponse.BodyHandlers.ofString());                  // after the batch, so the result is predictable
        System.out.println("GET /scores -> " + all.statusCode() + " " + all.body());
        server.stop(0);
    }
}

Check your understanding

  1. Which status code fits a successful DELETE that returns no body?

  2. What does client.sendAsync(...) return?

  3. Why set a connectTimeout on the HttpClient?

  4. In real projects, what should you use instead of building JSON strings by hand?

Ask AI