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.
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) andHttpServership 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.
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);
}
}