Un servidor web en Java sin dependencias

En el JDK hay un servidor HTTP desde Java 6 y casi nadie lo usa. No sustituye a Spring, pero para una herramienta interna, un punto de salud o una maqueta, evita añadir un marco de trabajo entero.

Levantarlo

HttpServer s = HttpServer.create(new InetSocketAddress(0), 0);   // 0 = puerto libre
s.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
s.createContext("/hola", ic -> responder(ic, 200, "text/plain; charset=utf-8", "Hola desde el JDK"));
s.start();
salidaescuchando en http://localhost:33657

Tres detalles de esas cuatro líneas. El puerto 0 hace que el sistema asigne uno libre, que es lo que conviene en pruebas para no chocar con nada; se recupera luego con s.getAddress().getPort(). El segundo argumento de create es el tamaño de la cola de conexiones pendientes. Y el ejecutor de hilos virtuales es lo que convierte este servidor de juguete en algo que aguanta miles de peticiones simultáneas: sin él, atiende de una en una.

Responder

void responder(HttpExchange ic, int codigo, String tipo, String cuerpo) throws IOException {
    byte[] b = cuerpo.getBytes(StandardCharsets.UTF_8);
    ic.getResponseHeaders().set("Content-Type", tipo);
    ic.sendResponseHeaders(codigo, b.length);
    try (var os = ic.getResponseBody()) { os.write(b); }
}
El orden importa y la API no perdona. Las cabeceras se ponen antes de sendResponseHeaders; después ya se han enviado y no se puede cambiar nada. Y la longitud va en bytes, no en caracteres: con acentos no es lo mismo. Si te equivocas ahí, el cliente se queda esperando. El try con recursos cierra el flujo, que es lo que da por terminada la respuesta.

Parámetros de consulta

s.createContext("/saluda", ic -> {
    var q = consulta(ic.getRequestURI().getQuery());
    String nombre = q.getOrDefault("nombre", "mundo");
    responder(ic, 200, "application/json; charset=utf-8",
              "{\"saludo\":\"hola, " + nombre + "\"}");
});

No hay nada que analice la cadena de consulta: llega tal cual en getQuery() y hay que partirla a mano, sin olvidar URLDecoder para los %20 y los acentos.

POST con cuerpo, y el método equivocado

s.createContext("/eco", ic -> {
    if (!"POST".equals(ic.getRequestMethod())) { responder(ic, 405, "text/plain", "solo POST"); return; }
    String cuerpo = new String(ic.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
    responder(ic, 200, "text/plain; charset=utf-8", "recibí " + cuerpo.length() + " bytes: " + cuerpo);
});

Probándolo todo desde el mismo programa, con el cliente HTTP del JDK:

salidaGET /hola               ->  200  Hola desde el JDK
  GET /saluda?nombre=Ana  ->  200  {"saludo":"hola, Ana"}
  GET /noexiste           ->  404  <h1>404 Not Found</h1>No context found for request
  POST /eco               ->  200  recibí 17 bytes: mensaje de prueba
  GET  /eco               ->  405  solo POST

El 404 lo da el servidor solo cuando ninguna ruta encaja. Las rutas se emparejan por prefijo y gana la más larga, así que un contexto en / actúa de comodín para todo lo demás.

Lo que no tiene

Conviene saber dónde está el límite: no hay rutas con parámetros (/libros/{id}), ni inyección de dependencias, ni serialización de JSON, ni HTTPS cómodo, ni middleware. Todo eso hay que escribirlo o traerlo de fuera.

Para qué sí vale: un punto de salud o de métricas dentro de un proceso que ya existe, una maqueta sin dependencias, una herramienta interna, o un servidor falso en las pruebas de integración. Para una aplicación web de verdad, Spring Boot, Quarkus o Javalin.

Y si lo único que quieres es servir una carpeta de archivos, desde Java 18 el JDK trae una orden hecha: jwebserver -p 8000 -d /ruta. Sin escribir nada.

Todo el código se compiló y se ejecutó con Java 25 LTS en un contenedor limpio antes de publicar esta página; las salidas están copiadas de esa ejecución.