Blog

HTTP en Java con el JDK: HttpClient y un pequeño HttpServer

El JDK de Java puede llamar y servir HTTP sin bibliotecas. Aprende HttpClient, síncrono y asíncrono, y luego construye un pequeño servicio de tareas en JSON sobre el HttpServer incorporado, con hilos virtuales, códigos de estado correctos y un apagado limpio.

El JDK trae las dos mitades de HTTP. java.net.http.HttpClient envía peticiones, y com.sun.net.httpserver.HttpServer las responde. Ninguno necesita una biblioteca, y juntos alcanzan para un pequeño servicio interno, un servidor falso en un test o una herramienta que llama a una API.

Este post cubre el cliente, las peticiones asíncronas, los timeouts, las redirecciones y las reglas del servidor para las respuestas, y luego construye un pequeño servicio de tareas como un módulo real. Cada programa de abajo se ejecutó en Java 25, y su salida está copiada de esa ejecución. Para ejecutar uno tú mismo, guárdalo como Main.java y ejecuta java Main.java. La máquina que revisa estos programas no tiene acceso a internet, así que cada uno arranca su propio servidor en el puerto 0 y lo llama.

Un servidor en el puerto 0, y un cliente para llamarlo

Un HttpServer escucha en un puerto y le pasa cada petición a un handler registrado para una ruta. El puerto 0 le pide al sistema operativo cualquier puerto libre, que es lo que quieren los ejemplos y los tests. Este programa arranca un servidor con un handler, lo llama cuatro veces y lo detiene:

import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

void main() throws Exception {
    // Port 0 asks the operating system for any free port.
    HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
    server.createContext("/hello", exchange -> {
        String text = "hello, you asked for " + exchange.getRequestURI().getPath() + "\n";
        byte[] body = text.getBytes(StandardCharsets.UTF_8);
        exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
        exchange.sendResponseHeaders(200, body.length);
        try (var out = exchange.getResponseBody()) {
            out.write(body);
        }
    });
    server.start();
    int port = server.getAddress().getPort();

    try (HttpClient client = HttpClient.newHttpClient()) {
        for (String path : List.of("/hello", "/hello/world", "/helloworld", "/")) {
            var uri = URI.create("http://localhost:" + port + path);
            var response = client.send(HttpRequest.newBuilder(uri).build(),
                    HttpResponse.BodyHandlers.ofString());
            IO.println(response.statusCode() + " " + path + ": " + response.body().strip());
        }
    }
    server.stop(0);
}

Imprime:

200 /hello: hello, you asked for /hello
200 /hello/world: hello, you asked for /hello/world
200 /helloworld: hello, you asked for /helloworld
404 /: <h1>404 Not Found</h1>No context found for request

El handler recibe un HttpExchange, que contiene la petición y la respuesta. Fija un header, llama a sendResponseHeaders con el estado y la longitud del cuerpo en bytes, y después escribe el cuerpo. getAddress().getPort() dice qué puerto obtuvo el servidor, y stop(0) lo detiene sin esperar.

Mira /helloworld. Un contexto coincide con cualquier ruta que empiece con su cadena, así que /hello coincidió con ella. Una ruta sin contexto recibe el 404 en HTML del propio JDK. Un handler al que le importan las rutas exactas tiene que revisarlas él mismo.

Los imports no son opcionales. Un archivo fuente compacto importa java.base por ti, pero java.net.http y jdk.httpserver son módulos aparte.

HttpClient: construir una petición, enviarla, leer la respuesta

Un HttpClient envía objetos HttpRequest y devuelve objetos HttpResponse. Construye uno y reutilízalo, porque mantiene un pool de conexiones abiertas. HttpClient pasó a ser una API estándar en Java 11, y javac --release 10 no encuentra el paquete java.net.http.

import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

void main() throws Exception {
    HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
    server.createContext("/echo", exchange -> {
        String sent = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
        String text = exchange.getRequestMethod() + " with "
                + exchange.getRequestHeaders().getFirst("Content-Type") + ": " + sent;
        byte[] body = text.getBytes(StandardCharsets.UTF_8);
        exchange.getResponseHeaders().set("X-Echo-Length", String.valueOf(sent.length()));
        exchange.sendResponseHeaders(200, body.length);
        try (var out = exchange.getResponseBody()) {
            out.write(body);
        }
    });
    server.start();
    URI echo = URI.create("http://localhost:" + server.getAddress().getPort() + "/echo");

    try (HttpClient client = HttpClient.newHttpClient()) {
        HttpRequest request = HttpRequest.newBuilder(echo)
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString("{\"title\":\"Buy milk\"}"))
                .build();
        HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());

        IO.println("status:  " + response.statusCode());
        IO.println("body:    " + response.body());
        IO.println("header:  " + response.headers().firstValue("X-Echo-Length").orElse("none"));
        IO.println("asked:   " + client.version());
        IO.println("got:     " + response.version());
    }
    server.stop(0);
}

Imprime:

status:  200
body:    POST with application/json: {"title":"Buy milk"}
header:  20
asked:   HTTP_2
got:     HTTP_1_1

send se bloquea hasta que llega la respuesta. El body handler decide en qué se convierte el cuerpo: ofString() aquí, o ofByteArray(), ofFile(path) y discarding(). headers().firstValue devuelve un Optional<String> e ignora mayúsculas y minúsculas en el nombre.

Las dos últimas líneas importan más adelante. El cliente prefiere HTTP/2, pero el servidor del JDK respondió con HTTP/1.1, y el cliente usó eso sin decir nada. El bloque try cierra el cliente, algo que HttpClient permite desde Java 21.

sendAsync devuelve un CompletableFuture

sendAsync inicia la petición y retorna enseguida, con un CompletableFuture que se completa cuando llega la respuesta. La parte sobre java.util.concurrent cubre el propio CompletableFuture.

import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

void main() throws Exception {
    HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
    server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
    server.createContext("/square", exchange -> {
        int n = Integer.parseInt(exchange.getRequestURI().getQuery().substring("n=".length()));
        byte[] body = String.valueOf(n * n).getBytes(StandardCharsets.UTF_8);
        exchange.sendResponseHeaders(200, body.length);
        try (var out = exchange.getResponseBody()) {
            out.write(body);
        }
    });
    server.start();
    String base = "http://localhost:" + server.getAddress().getPort();

    try (HttpClient client = HttpClient.newHttpClient()) {
        List<CompletableFuture<String>> futures = new ArrayList<>();
        for (int n = 1; n <= 5; n++) {
            var request = HttpRequest.newBuilder(URI.create(base + "/square?n=" + n)).build();
            CompletableFuture<String> future = client
                    .sendAsync(request, HttpResponse.BodyHandlers.ofString())
                    .thenApply(HttpResponse::body);
            futures.add(future);
        }
        IO.println("sent " + futures.size() + " requests, none of them waited for another");

        CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).join();
        IO.println(futures.stream().map(CompletableFuture::join).toList());
    }
    server.stop(0);
}

Imprime:

sent 5 requests, none of them waited for another
[1, 4, 9, 16, 25]

El bucle inicia cinco peticiones sin esperar a ninguna. thenApply(HttpResponse::body) convierte cada future de una respuesta en un future de su cuerpo. CompletableFuture.allOf espera a las cinco, y después de eso cada join retorna de inmediato. Los resultados salen en el orden en que los futures entraron a la lista, no en el orden en que llegaron las respuestas.

El servidor ejecuta cada petición en un hilo virtual, configurado con setExecutor. El servicio de más abajo explica por qué.

Timeouts y redirecciones

Un cliente sin timeout puede esperar para siempre a un servidor que nunca responde. HttpClient tiene dos timeouts: connectTimeout en el cliente, para abrir una conexión, y timeout en cada petición, para esperar la respuesta. Seguir redirecciones es una línea en el mismo builder:

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
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.net.http.HttpTimeoutException;

void reply(HttpExchange exchange, int status, String text) throws IOException {
    byte[] body = text.getBytes(StandardCharsets.UTF_8);
    exchange.sendResponseHeaders(status, body.length);
    try (var out = exchange.getResponseBody()) {
        out.write(body);
    }
}

void main() throws Exception {
    HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
    server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
    server.createContext("/old", exchange -> {
        exchange.getResponseHeaders().set("Location", "/new");
        reply(exchange, 302, "moved");
    });
    server.createContext("/new", exchange -> reply(exchange, 200, "the new page"));
    server.createContext("/slow", exchange -> {
        try {
            Thread.sleep(Duration.ofSeconds(2));
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        reply(exchange, 200, "finally");
    });
    server.start();
    String base = "http://localhost:" + server.getAddress().getPort();

    try (HttpClient plain = HttpClient.newHttpClient();
            HttpClient following = HttpClient.newBuilder()
                    .connectTimeout(Duration.ofSeconds(2))
                    .followRedirects(HttpClient.Redirect.NORMAL)
                    .build()) {
        var old = HttpRequest.newBuilder(URI.create(base + "/old")).build();

        var r1 = plain.send(old, HttpResponse.BodyHandlers.ofString());
        IO.println("plain:     " + r1.statusCode() + " " + r1.body()
                + ", Location " + r1.headers().firstValue("Location").orElse("none"));

        var r2 = following.send(old, HttpResponse.BodyHandlers.ofString());
        IO.println("following: " + r2.statusCode() + " " + r2.body()
                + ", from " + r2.uri().getPath());

        var slow = HttpRequest.newBuilder(URI.create(base + "/slow"))
                .timeout(Duration.ofMillis(200))
                .build();
        try {
            following.send(slow, HttpResponse.BodyHandlers.ofString());
        } catch (HttpTimeoutException e) {
            IO.println("slow:      " + e.getClass().getSimpleName() + ": " + e.getMessage());
        }
    }
    server.stop(0);
}

Imprime:

plain:     302 moved, Location /new
following: 200 the new page, from /new
slow:      HttpTimeoutException: request timed out

La política de redirección por defecto es Redirect.NEVER, así que el cliente simple devolvió el 302 y su header Location. Redirect.NORMAL sigue las redirecciones, salvo de una URL HTTPS a una HTTP, y response.uri() muestra de dónde vino la respuesta final.

La petición lenta lanzó HttpTimeoutException a los 200 milisegundos, mientras el handler del servidor seguía dormido. Es una subclase de IOException.

El proyecto: un servicio de tareas en un módulo

El resto de este post construye un pequeño servicio que guarda una lista de tareas en memoria y la sirve como JSON. Es un módulo con nombre, com.example.tasks, sin herramienta de build y sin bibliotecas. Responde cuatro peticiones:

Petición Qué hace Éxito Errores del cliente
GET /tasks lista todas las tareas, ordenadas por id 200
POST /tasks crea una tarea 201 con Location 400, 413, 415, 422
GET /tasks/{id} trae una tarea 200 404
DELETE /tasks/{id} borra una tarea 204 404

Una ruta conocida con el método equivocado recibe 405 y un header Allow. Cualquier otra ruta recibe 404, y un bug en el código recibe 500. Todos los errores tienen la misma forma de cuerpo, {"error":"..."}. Este es el proyecto completo, salvo su .gitignore:

19-tasks-service/
├── run-checks.sh
├── checks/
│   └── SmokeCheck.java
└── src/
    └── com.example.tasks/
        ├── module-info.java
        └── com/example/tasks/
            ├── Main.java
            ├── http/
            │   ├── Json.java
            │   ├── TaskHandler.java
            │   └── TaskServer.java
            └── store/
                ├── InMemoryTaskStore.java
                ├── Task.java
                └── TaskStore.java

El módulo necesita el módulo del servidor HTTP del JDK y exporta dos paquetes:

/** A small task list over HTTP, built on the JDK's own HTTP server. */
module com.example.tasks {
    requires jdk.httpserver;

    exports com.example.tasks.http;
    exports com.example.tasks.store;
}

Como http y store están exportados, otro código, como un test, puede arrancar el servidor con cualquier store. La parte sobre testing y distribución construye un runtime pequeño a partir de este módulo con jlink.

El store: una interfaz delante de un map

Una tarea es record Task(long id, String title, boolean done), y el código HTTP llega a las tareas solo a través de una interfaz. Así un test, o una versión futura con base de datos, puede pasar un store distinto:

package com.example.tasks.store;

import java.util.List;
import java.util.Optional;

/** Everything the HTTP layer needs from storage. Implementations must be thread-safe. */
public interface TaskStore {
    /** Returns every task, sorted by id. */
    List<Task> list();

    /** Returns the task with this id, or an empty Optional. */
    Optional<Task> get(long id);

    /** Stores a new task under the next id and returns it. */
    Task create(String title, boolean done);

    /** Deletes the task with this id. Returns false if there was none. */
    boolean delete(long id);
}

La versión en memoria usa un ConcurrentHashMap para las tareas y un AtomicLong para los ids:

/** Keeps tasks in memory. Safe for many requests at once; lost on restart. */
public final class InMemoryTaskStore implements TaskStore {
    private final ConcurrentHashMap<Long, Task> tasks = new ConcurrentHashMap<>();
    private final AtomicLong lastId = new AtomicLong();

    /** Creates an empty store. The first task gets id 1. */
    public InMemoryTaskStore() {
    }

    @Override
    public List<Task> list() {
        return tasks.values().stream()
                .sorted(Comparator.comparingLong(Task::id))
                .toList();
    }

    @Override
    public Optional<Task> get(long id) {
        return Optional.ofNullable(tasks.get(id));
    }

    @Override
    public Task create(String title, boolean done) {
        var task = new Task(lastId.incrementAndGet(), title, done);
        tasks.put(task.id(), task);
        return task;
    }

    @Override
    public boolean delete(long id) {
        return tasks.remove(id) != null;
    }
}

Las peticiones se ejecutan al mismo tiempo, así que dos peticiones POST pueden llegar juntas a create. incrementAndGet igual le da a cada una un id distinto, sin lock. list ordena por id, porque un hash map no tiene un orden útil. Un list que corre durante un create puede incluir o no la tarea nueva, ya que recorrer un ConcurrentHashMap no lo congela.

Un hilo virtual por petición

TaskServer construye el servidor. Recibe sus dependencias, un store y un puerto, como argumentos, así que un test puede arrancar uno en el puerto 0 con un store nuevo:

    /**
     * Starts serving the store on the given port, on every local address.
     * Port 0 asks the operating system for a free port; port() says which one it got.
     */
    public static TaskServer start(TaskStore store, int port) throws IOException {
        HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
        server.setExecutor(executor);
        server.createContext("/", new TaskHandler(store));
        server.start();
        return new TaskServer(server, executor);
    }

Un solo contexto en / manda todas las peticiones a un handler. La línea setExecutor decide qué hilo ejecuta cada petición. Sin ella, el JDK ejecuta todos los handlers en el único hilo despachador (dispatcher) del servidor, una petición a la vez. Este programa envía cinco peticiones a la vez a un handler que espera 300 milisegundos, primero sin executor y después con hilos virtuales:

import com.sun.net.httpserver.HttpServer;
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.util.concurrent.atomic.AtomicInteger;

/** Sends 5 requests at once. Each handler waits 300 ms, like a slow database call. */
void run(String label, Executor executor) throws Exception {
    var inFlight = new AtomicInteger();
    var mostAtOnce = new AtomicInteger();
    var virtual = new AtomicInteger();

    HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
    server.setExecutor(executor);
    server.createContext("/work", exchange -> {
        mostAtOnce.accumulateAndGet(inFlight.incrementAndGet(), Math::max);
        if (Thread.currentThread().isVirtual()) {
            virtual.incrementAndGet();
        }
        try {
            Thread.sleep(300);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        inFlight.decrementAndGet();
        exchange.sendResponseHeaders(204, -1);
        exchange.close();
    });
    server.start();

    var uri = URI.create("http://localhost:" + server.getAddress().getPort() + "/work");
    try (HttpClient client = HttpClient.newHttpClient()) {
        var futures = new ArrayList<CompletableFuture<HttpResponse<Void>>>();
        for (int i = 0; i < 5; i++) {
            futures.add(client.sendAsync(HttpRequest.newBuilder(uri).build(),
                    HttpResponse.BodyHandlers.discarding()));
        }
        futures.forEach(CompletableFuture::join);
    }
    server.stop(0);
    IO.println(label + ": at most " + mostAtOnce.get() + " at once, "
            + virtual.get() + " of 5 on virtual threads");
}

void main() throws Exception {
    run("no executor    ", null);
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        run("virtual threads", executor);
    }
}

Imprime:

no executor    : at most 1 at once, 0 of 5 on virtual threads
virtual threads: at most 5 at once, 5 of 5 on virtual threads

Sin executor, las peticiones hicieron fila una detrás de otra. Con Executors.newVirtualThreadPerTaskExecutor(), los cinco handlers estaban esperando en el mismo momento, cada uno en su propio hilo virtual. Los hilos virtuales pasaron a ser definitivos en Java 21, y su propia parte cubre cómo funcionan.

Explicado como si tuvieras diez años

Piensa en el servidor como una tienda con un solo mostrador. Una persona está parada en la puerta y se fija en cada cliente que entra.

Sin executor, esa misma persona también atiende a cada cliente. Si un cliente pide algo del depósito, todos los que están detrás esperan hasta que vuelva.

Con hilos virtuales, cada cliente recibe su propio ayudante apenas entra. Los ayudantes casi no cuestan nada, así que la tienda puede tener miles. Mientras un ayudante espera al depósito, se aparta del mostrador, y otro ayudante lo usa.

La versión precisa

HttpServer tiene un hilo despachador que vigila todas las conexiones. Cuando una petición está lista, el despachador le entrega una tarea al executor, y esa tarea lee la petición y llama a tu handler. newVirtualThreadPerTaskExecutor() arranca un hilo virtual nuevo para cada tarea. Cuando el handler se bloquea, por ejemplo en Thread.sleep o en una lectura de socket, su hilo virtual suelta el hilo de plataforma en el que se estaba ejecutando, llamado su carrier (hilo portador). Hay solo unos pocos carriers, más o menos uno por núcleo de CPU por defecto, y quedan libres para ejecutar otros hilos virtuales.

TaskServer.stop cierra el executor después de server.stop, porque HttpServer nunca cierra un executor que le diste.

Dónde falla la analogía: los ayudantes no agrandan el depósito. Si diez mil peticiones esperan todas a una base de datos que permite diez conexiones, 9.990 hilos virtuales hacen fila ahí. Los hilos virtuales tampoco aceleran el trabajo que usa la CPU todo el tiempo, porque ese trabajo necesita un carrier mientras dura. Ayudan cuando las peticiones pasan la mayor parte del tiempo esperando.

Enrutar por método y ruta dentro del handler

El handler convierte un método y una ruta en una llamada al store. El JDK no tiene router, así que son if y switch simples:

    @Override
    public void handle(HttpExchange exchange) throws IOException {
        try (exchange) {
            try {
                route(exchange);
            } catch (RequestException e) {
                sendError(exchange, e.status, e.getMessage());
            } catch (RuntimeException e) {
                LOG.log(Level.ERROR, "request failed: " + exchange.getRequestURI(), e);
                if (exchange.getResponseCode() == -1) {
                    sendError(exchange, 500, "internal server error");
                }
            }
        }
    }

    private void route(HttpExchange exchange) throws IOException, RequestException {
        String method = exchange.getRequestMethod();
        String path = exchange.getRequestURI().getPath();

        if (path.equals("/tasks")) {
            switch (method) {
                case "GET" -> sendJson(exchange, 200, Json.tasks(store.list()));
                case "POST" -> createTask(exchange);
                default -> throw methodNotAllowed(exchange, "GET, POST");
            }
        } else if (path.startsWith("/tasks/")) {
            String rawId = path.substring("/tasks/".length());
            switch (method) {
                case "GET" -> getTask(exchange, parseId(rawId));
                case "DELETE" -> deleteTask(exchange, parseId(rawId));
                default -> throw methodNotAllowed(exchange, "GET, DELETE");
            }
        } else {
            throw new RequestException(404, "not found");
        }
    }

HttpExchange es AutoCloseable, así que el try de afuera siempre termina el exchange. El de adentro convierte una RequestException en una respuesta de error. Cualquier otra RuntimeException es un bug: se registra en el log, y el cliente recibe un 500 si todavía no salió ningún estado, que es lo que significa getResponseCode() == -1. Sin ese catch, el JDK simplemente cierra la conexión, y el cliente no recibe ningún estado.

route compara la ruta completa, así que /tasksfoo es un 404. Un método equivocado recibe un 405 con el header Allow que HTTP exige en ese caso:

    private static RequestException methodNotAllowed(HttpExchange exchange, String allow) {
        exchange.getResponseHeaders().set("Allow", allow);
        return new RequestException(405, "method not allowed");
    }

    /** Only a positive decimal number names a task: "7" does; "+7", "07x" and "-3" don't. */
    private static long parseId(String raw) throws RequestException {
        boolean digits = !raw.isEmpty() && raw.length() <= 18
                && raw.chars().allMatch(c -> c >= '0' && c <= '9');
        long id = digits ? Long.parseLong(raw) : 0;
        if (id <= 0) {
            throw new RequestException(404, "task not found");
        }
        return id;
    }

La revisión de dígitos va antes de Long.parseLong, porque parseLong acepta "+7". /tasks/abc, /tasks/0 y /tasks/+7 responden todos 404. RequestException es una excepción comprobada que lleva un estado:

    /** A problem with the request that the client can fix. */
    static final class RequestException extends Exception {
        @Serial
        private static final long serialVersionUID = 1L;

        final int status;

        RequestException(int status, String message) {
            super(message);
            this.status = status;
        }
    }

Leer el cuerpo de una petición con un límite de tamaño

El cuerpo de una petición viene de quien la envió, así que el servicio revisa su tipo y limita su tamaño antes de parsearlo:

    private void createTask(HttpExchange exchange) throws IOException, RequestException {
        requireJson(exchange);
        NewTask input = parseNewTask(readBody(exchange));
        Task task = store.create(input.title(), input.done());
        exchange.getResponseHeaders().set("Location", "/tasks/" + task.id());
        sendJson(exchange, 201, Json.task(task));
    }

    private static void requireJson(HttpExchange exchange) throws RequestException {
        String type = exchange.getRequestHeaders().getFirst("Content-Type");
        String mediaType = type == null ? "" : type.split(";", 2)[0].strip();
        if (!mediaType.equalsIgnoreCase("application/json")) {
            throw new RequestException(415, "Content-Type must be application/json");
        }
    }

    /** Reads the whole body as UTF-8, but never more than MAX_BODY_BYTES + 1 bytes of it. */
    private static String readBody(HttpExchange exchange) throws IOException, RequestException {
        byte[] bytes;
        try (InputStream in = exchange.getRequestBody()) {
            bytes = in.readNBytes(MAX_BODY_BYTES + 1);
        }
        if (bytes.length > MAX_BODY_BYTES) {
            throw new RequestException(413,
                    "request body must not be larger than " + MAX_BODY_BYTES + " bytes");
        }
        try {
            return StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString();
        } catch (CharacterCodingException e) {
            throw new RequestException(400, "request body must be UTF-8");
        }
    }

requireJson responde 415 Unsupported Media Type cuando el Content-Type no es application/json. El cuerpo podría estar bien, y el estado dice que lo que está mal es la etiqueta.

readNBytes(MAX_BODY_BYTES + 1) nunca lee más de 16.385 bytes, y ese byte extra distingue “demasiado grande” de “justo en el límite”. Pasado el límite, la respuesta es 413 Content Too Large. El código cuenta bytes en lugar de confiar en Content-Length, que un cliente puede poner mal u omitir.

El decodificador de newDecoder() reporta el UTF-8 inválido como un error. new String(bytes, UTF_8) en cambio reemplazaría en silencio los bytes inválidos con .

JSON sin bibliotecas

El JDK no tiene una API de JSON, y en Java 25 no hay ningún módulo para eso:

$ java --list-modules | grep -c .
69
$ java --list-modules | grep -ci json
0

Los servicios reales usan una biblioteca como Jackson o Gson. Este proyecto usa solo el JDK, así que escribe JSON a mano y lee un subconjunto pequeño y estricto. Escribir es la mitad fácil:

    static String task(Task task) {
        return "{\"id\":" + task.id()
                + ",\"title\":" + string(task.title())
                + ",\"done\":" + task.done() + "}";
    }

    static String tasks(List<Task> tasks) {
        return tasks.stream().map(Json::task).collect(Collectors.joining(",", "[", "]"));
    }

    static String error(String message) {
        return "{\"error\":" + string(message) + "}";
    }

    /** Quotes a string, escaping what JSON requires: quote, backslash, control characters. */
    static String string(String s) {
        var out = new StringBuilder(s.length() + 2).append('"');
        for (int i = 0; i < s.length(); i++) {
            char c = s.charAt(i);
            switch (c) {
                case '"' -> out.append("\\\"");
                case '\\' -> out.append("\\\\");
                case '\n' -> out.append("\\n");
                case '\r' -> out.append("\\r");
                case '\t' -> out.append("\\t");
                default -> {
                    if (c < 0x20) {
                        out.append(String.format("\\u%04x", (int) c));
                    } else {
                        out.append(c);
                    }
                }
            }
        }
        return out.append('"').toString();
    }

JSON exige escapes para una comilla, una barra invertida y los caracteres de control. Todo lo demás puede salir tal como está.

Leer fue la decisión más difícil. Una opción honesta era aceptar un título en text/plain y no parsear JSON en absoluto. Pero los clientes de una API JSON envían JSON, así que el servicio lo acepta y dice exactamente cuánto:

    /**
     * Parses one flat JSON object whose values are strings, true or false.
     * Numbers, null, arrays, nested objects and repeated names are rejected
     * on purpose: this service never needs them.
     */
    static Map<String, Object> parseObject(String text) {
        return new Parser(text).object();
    }

El parser lee un objeto plano cuyos valores son cadenas, true o false. Cualquier otra cosa recibe un 400 con un mensaje, nunca una suposición. Eso cubre lo que necesita una tarea. El día que el servicio necesite más es el día de agregar una biblioteca, no de hacer crecer este parser.

Después, parseNewTask revisa los campos:

    private static NewTask parseNewTask(String body) throws RequestException {
        Map<String, Object> fields;
        try {
            fields = Json.parseObject(body);
        } catch (Json.BadJson e) {
            throw new RequestException(400, e.getMessage());
        }
        for (String name : fields.keySet()) {
            if (!name.equals("title") && !name.equals("done")) {
                throw new RequestException(400, "unknown field " + Json.string(name));
            }
        }
        if (!(fields.getOrDefault("title", "") instanceof String rawTitle)) {
            throw new RequestException(400, "field \"title\" must be a string");
        }
        if (!(fields.getOrDefault("done", false) instanceof Boolean done)) {
            throw new RequestException(400, "field \"done\" must be true or false");
        }
        String title = rawTitle.strip();
        if (title.isEmpty()) {
            throw new RequestException(422, "title must not be empty");
        }
        if (title.codePointCount(0, title.length()) > MAX_TITLE_LENGTH) {
            throw new RequestException(422,
                    "title must be at most " + MAX_TITLE_LENGTH + " characters");
        }
        return new NewTask(title, done);
    }

Un campo desconocido como "id" es un 400, así que un cliente no puede elegir su propio id y un error de tipeo como "titel" no se ignora en silencio. Un título vacío recibe 422 Unprocessable Content: el servidor leyó bien la petición, pero un valor rompe una regla. La longitud cuenta code points, así que un título en chino también tiene 200 caracteres.

Escribir respuestas: las tres longitudes de sendResponseHeaders

sendResponseHeaders(status, length) envía la línea de estado y los headers, y su segundo argumento dice qué tipo de cuerpo viene después. Todas las respuestas JSON del servicio pasan por un solo método:

    private static void sendJson(HttpExchange exchange, int status, String json)
            throws IOException {
        byte[] body = (json + "\n").getBytes(StandardCharsets.UTF_8);
        exchange.getResponseHeaders().set("Content-Type", "application/json");
        exchange.sendResponseHeaders(status, body.length);
        try (OutputStream out = exchange.getResponseBody()) {
            out.write(body);
        }
    }

    private static void sendError(HttpExchange exchange, int status, String message)
            throws IOException {
        sendJson(exchange, status, Json.error(message));
    }

Los headers se fijan primero, porque salen junto con el estado. La longitud está en bytes UTF-8, no en caracteres: "Zoë" tiene 3 caracteres pero 4 bytes. Un borrado no envía cuerpo:

    private void deleteTask(HttpExchange exchange, long id) throws IOException, RequestException {
        if (!store.delete(id)) {
            throw new RequestException(404, "task not found");
        }
        exchange.sendResponseHeaders(204, -1);
    }

El -1 importa, y también la diferencia con 0. Este programa prueba cada tipo de longitud, más una que miente:

import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

void main() throws Exception {
    HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
    byte[] hello = "hello".getBytes(StandardCharsets.UTF_8);

    server.createContext("/length", exchange -> {
        exchange.sendResponseHeaders(200, hello.length);   // exactly 5 bytes follow
        try (var out = exchange.getResponseBody()) {
            out.write(hello);
        }
    });
    server.createContext("/chunked", exchange -> {
        exchange.sendResponseHeaders(200, 0);              // 0: any amount, sent in chunks
        try (var out = exchange.getResponseBody()) {
            out.write(hello);
            out.write(hello);
        }
    });
    server.createContext("/nobody", exchange -> {
        exchange.sendResponseHeaders(204, -1);             // -1: no body at all
        exchange.close();
    });
    server.createContext("/toomany", exchange -> {
        exchange.sendResponseHeaders(200, 2);              // promises 2 bytes, writes 5
        try (var out = exchange.getResponseBody()) {
            out.write(hello);
        } catch (IOException e) {
            IO.println("  server: " + e.getMessage());
            throw e;
        }
    });
    server.start();
    String base = "http://localhost:" + server.getAddress().getPort();

    try (HttpClient client = HttpClient.newHttpClient()) {
        for (String path : List.of("/length", "/chunked", "/nobody", "/toomany")) {
            IO.println(path);
            var request = HttpRequest.newBuilder(URI.create(base + path))
                    .POST(HttpRequest.BodyPublishers.noBody())
                    .build();
            try {
                var response = client.send(request, HttpResponse.BodyHandlers.ofString());
                var headers = response.headers();
                IO.println("  " + response.statusCode() + " [" + response.body() + "]"
                        + " content-length=" + headers.firstValue("content-length").orElse("-")
                        + " transfer-encoding="
                        + headers.firstValue("transfer-encoding").orElse("-"));
            } catch (IOException e) {
                IO.println("  client: " + e.getMessage());
            }
        }
    }
    server.stop(0);
}

Imprime:

/length
  200 [hello] content-length=5 transfer-encoding=-
/chunked
  200 [hellohello] content-length=- transfer-encoding=chunked
/nobody
  204 [] content-length=- transfer-encoding=-
/toomany
  server: too many bytes to write to stream
  client: HTTP/1.1 header parser received no bytes

Una longitud positiva envía un header Content-Length, y el cuerpo tiene que medir exactamente eso. 0 no significa “vacío”. Significa “longitud desconocida”, así que el servidor envía el cuerpo en fragmentos (chunks) de cualquier tamaño. -1 significa sin cuerpo. Un 204 nunca debe tener uno.

Cuando pasamos 0 con un 204, el JDK registró WARNING: sendResponseHeaders: rCode = 204: forcing contentLen = -1 en la salida de error estándar. Escribir más bytes de los prometidos lanza IOException. El handler la deja escapar, así que el servidor cierra la conexión. Nuestra primera versión capturaba la excepción y seguía como si nada, y su cliente se quedó colgado hasta que lo matamos.

Códigos de estado y una sola forma de error

Un cliente lee primero el código de estado, así que el servicio elige cada uno a propósito, y todos los cuerpos de error tienen la misma forma, {"error":"..."}:

  • 400: el cuerpo no se puede leer: JSON inválido, un campo desconocido, un tipo equivocado o UTF-8 inválido.
  • 404: no existe esa tarea, o no existe esa ruta.
  • 405: la ruta existe, pero no con ese método. Allow lista los métodos que funcionan.
  • 413: el cuerpo pasa de 16 KiB.
  • 415: el Content-Type no es application/json.
  • 422: el título está vacío o es demasiado largo.
  • 500: un bug. Los detalles van al log, no al cliente.

Main: arrancar en un puerto, detenerse con un shutdown hook

Main lee un puerto opcional de sus argumentos, arranca el servicio con un store en memoria y registra un shutdown hook:

package com.example.tasks;

import java.io.IOException;

import com.example.tasks.http.TaskServer;
import com.example.tasks.store.InMemoryTaskStore;

/** Starts the tasks service. Usage: Main [port], where the port defaults to 8080. */
public final class Main {
    private Main() {
    }

    public static void main(String[] args) throws IOException {
        int port = args.length == 0 ? 8080 : parsePort(args);
        TaskServer server = TaskServer.start(new InMemoryTaskStore(), port);
        IO.println("listening on port " + server.port());

        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            IO.println("stopping");
            server.stop(2);
            IO.println("stopped");
        }));
    }

    private static int parsePort(String[] args) {
        try {
            int port = Integer.parseInt(args[0]);
            if (args.length == 1 && port >= 0 && port <= 65535) {
                return port;
            }
        } catch (NumberFormatException e) {
            // Fall through to the usage message.
        }
        System.err.println("usage: tasks [port]   (0 to 65535, default 8080)");
        System.exit(2);
        return -1;
    }
}

main retorna enseguida, y la JVM sigue corriendo, porque el hilo despachador del servidor no es un hilo daemon.

Un shutdown hook (gancho de apagado) es un hilo que la JVM arranca cuando se le pide terminar, incluso con Ctrl+C y SIGTERM. Este llama a stop(2), que deja de aceptar conexiones y les da a las peticiones en curso hasta dos segundos para terminar. Los hooks no se ejecutan con kill -9.

Ejecutar el servicio y llamarlo con curl

El servicio se ejecuta desde el module path, como cualquier programa modular. En la carpeta del proyecto, compílalo y arráncalo en una terminal:

$ javac -Xlint:all -Werror --release 25 -d out --module-source-path src -m com.example.tasks
$ java -p out -m com.example.tasks/com.example.tasks.Main
listening on port 8080

Después llámalo desde una segunda terminal. -d hace que curl envíe un POST, y -w '%{http_code}\n' imprime el estado después del cuerpo:

$ curl -s localhost:8080/tasks
[]
$ curl -s -i localhost:8080/tasks -H 'Content-Type: application/json' -d '{"title": "Buy milk"}' | grep -v '^Date:'
HTTP/1.1 201 Created
Content-type: application/json
Content-length: 41
Location: /tasks/1

{"id":1,"title":"Buy milk","done":false}
$ curl -s localhost:8080/tasks -H 'Content-Type: application/json' -d '{"title": "Walk the dog", "done": true}'
{"id":2,"title":"Walk the dog","done":true}
$ curl -s localhost:8080/tasks
[{"id":1,"title":"Buy milk","done":false},{"id":2,"title":"Walk the dog","done":true}]
$ curl -s -X DELETE localhost:8080/tasks/1 -w '%{http_code}\n'
204
$ curl -s localhost:8080/tasks/1 -w '%{http_code}\n'
{"error":"task not found"}
404
$ curl -s -i -X PATCH localhost:8080/tasks/2 | grep -v '^Date:'
HTTP/1.1 405 Method Not Allowed
Allow: GET, DELETE
Content-type: application/json
Content-length: 31

{"error":"method not allowed"}
$ curl -s localhost:8080/tasks -d 'title=Walk the dog' -w '%{http_code}\n'
{"error":"Content-Type must be application/json"}
415
$ curl -s localhost:8080/tasks -H 'Content-Type: application/json' -d '{"title": "  "}' -w '%{http_code}\n'
{"error":"title must not be empty"}
422
$ curl -s localhost:8080/tasks -H 'Content-Type: application/json' -d '{"title": 42}' -w '%{http_code}\n'
{"error":"only strings, true and false are accepted as values"}
400
$ printf '{"title": "%s"}' "$(printf 'a%.0s' {1..20000})" > big.json
$ curl -s localhost:8080/tasks -H 'Content-Type: application/json' -d @big.json -w '%{http_code}\n'
{"error":"request body must not be larger than 16384 bytes"}
413
$ curl -s --http2 localhost:8080/tasks -o /dev/null -w '%{http_version}\n'
1.1

El grep quita el header Date, que cambia cada segundo. El JDK envía Content-type, no Content-Type, porque solo pone en mayúscula la primera letra del nombre de un header. Los nombres de header no distinguen mayúsculas de minúsculas, así que a los clientes no les importa. El último comando pidió pasar a HTTP/2, y el servidor se quedó en 1.1.

Presionar Ctrl+C en la primera terminal imprimió stopping y stopped, y la JVM terminó con estado 130.

Revisar el proyecto solo con herramientas del JDK

El run-checks.sh del proyecto usa solo javac y java. Compila el módulo con los warnings como errores, y luego compila y ejecuta checks/SmokeCheck.java, que arranca el servicio en el puerto 0 en su propia JVM:

    public static void main(String[] args) throws Exception {
        try (TaskServer server = TaskServer.start(new InMemoryTaskStore(), 0);
                HttpClient c = HttpClient.newHttpClient()) {
            client = c;
            base = "http://localhost:" + server.port();
            checkEndpoints();
            checkBadRequests();
            checkConcurrentCreates();
        }
        if (failures > 0) {
            IO.println(failures + " check(s) failed");
            System.exit(1);
        }
    }

Llama a cada endpoint con HttpClient y compara códigos de estado, headers y cuerpos, incluidas 100 peticiones POST enviadas a la vez, que tienen que recibir 100 ids distintos. Después el script arranca el Main real en el puerto 0, le envía SIGTERM y revisa que el hook se haya ejecutado:

$ ./run-checks.sh
ok  compiled com.example.tasks
ok  smoke check: 45 checks passed
ok  Main starts on port 0 and stops cleanly on SIGTERM
ok  a bad port prints usage and exits 2
ok  tests: 18 passed, 0 failed
ok  java -p tasks.jar -m com.example.tasks starts and stops
ok  jlink image with com.example.tasks,java.base,jdk.httpserver starts and stops
all checks passed

Las primeras cuatro líneas son las revisiones de esta parte. Es un smoke check, no una suite de tests. Las últimas tres vienen de la parte siguiente, que construye un arnés de tests para este proyecto y lo empaqueta con jlink y jpackage.

Lo que com.sun.net.httpserver no hace

com.sun.net.httpserver es una API soportada del JDK a pesar de su nombre, exportada por el módulo jdk.httpserver. Sirve para pequeños servicios internos, herramientas y servidores falsos en tests. Para un servicio público en producción, deja mucho para que escribas tú:

  • No hay HTTP/2. HttpClient y curl --http2 obtuvieron HTTP/1.1, curl --http2-prior-knowledge no obtuvo respuesta, y el módulo no tiene clases de HTTP/2.
  • No hay router. Los contextos coinciden por prefijo de cadena, sin parámetros de ruta y sin enrutar por método.
  • No hay JSON ni validación. La clase Json la mantienes tú.
  • Los límites son propiedades del sistema, no una API. Los límites de tiempo y de headers son flags -D como sun.net.httpserver.maxReqTime.
  • No hay servidor WebSocket, aunque java.net.http tiene un cliente WebSocket.
  • HTTPS es manual. HttpsServer existe, pero el SSLContext lo configuras tú.

Para producción, las opciones comunes son Jetty, un framework construido sobre Netty como Vert.x o Micronaut, y Spring Boot. Agregan enrutamiento, HTTP/2, binding de JSON, configuración de TLS y métricas. La interfaz del store y los códigos de estado de este post se trasladan a cualquiera de ellos.

Qué recordar

  • HttpClient.newHttpClient() construye un cliente reutilizable. send se bloquea, sendAsync devuelve un CompletableFuture, y BodyHandlers.ofString() lee el cuerpo como texto.
  • Fija connectTimeout en el cliente y timeout en cada petición. Las redirecciones no se siguen a menos que configures Redirect.NORMAL.
  • Los contextos de HttpServer coinciden por prefijo de cadena, así que revisa la ruta y el método exactos en el handler. El puerto 0 elige un puerto libre, y getAddress().getPort() te dice cuál.
  • sendResponseHeaders recibe una cantidad de bytes: un número positivo es la longitud exacta, 0 significa chunked, y -1 significa sin cuerpo. Fija los headers antes de llamarlo.
  • Sin executor, un solo hilo atiende todas las peticiones. setExecutor(Executors.newVirtualThreadPerTaskExecutor()) le da a cada petición su propio hilo virtual.
  • El JDK no tiene una API de JSON. Escribe a mano un subconjunto pequeño y estricto, o usa Jackson o Gson.
  • Limita el cuerpo de las peticiones, responde todos los errores con una sola forma JSON y detén el servidor desde un shutdown hook.

El servidor HTTP del JDK alcanza para un servicio pequeño, siempre que sepas qué partes ahora te toca escribir a ti.

¿Qué tan útil te resultó este post?

¡Haz clic en un corazón para calificar!

Calificación promedio 0 / 5. Total de votos: 0

Todavía no hay votos. Sé el primero en calificar este post.