CodeGym /Cursos /Java Server /HttpHandler breve: pasos y utilidades

HttpHandler breve: pasos y utilidades

Java Server
Nivel 23, Lección 4
Disponible

1. HttpHandler breve y lectura de arriba a abajo

En este punto ya tenemos todos los bloques: ruteo por method + path, lectura de path/query/headers/body y sendJson(…) para la respuesta. Ahora solo queda ensamblarlos de manera que el handler no se convierta en un caos. Al empezar a escribir código de servidor sin framework, es muy fácil caer en la trampa: «Solo voy a hacer un endpoint… lo pongo rápido en handle()». Luego se agrega un segundo endpoint, un tercero, aparece el body, errores, cabeceras, y de repente handle() se convierte en una «novela de 300 líneas», donde en medio se lee JSON y al final aparece el ruteo. Por eso necesitamos un HttpHandler breve y guionizado.

Imagina que HttpHandler es como un administrador en una clínica. No trata los dientes (eso es el servicio/lógica de negocio), no fabrica implantes (repositorio/almacenamiento), y ni siquiera escribe el «historial de la enfermedad» como modelo de dominio. Su trabajo es más simple: recibir a la persona, entender «a qué médico», recoger los datos mínimos (nombre, queja), enviarlo al lugar correspondiente y dar un resultado claro. En código, esto significa que handle(…) debe ocuparse del transporte: recibir HttpExchange, elegir la ruta, parsear la entrada, llamar al método de la rama adecuada y enviar la respuesta.

Si mantienes HttpHandler breve, obtienes un beneficio muy práctico: el comportamiento del servidor se lee de arriba a abajo, como las instrucciones para montar un mueble (solo que sin la sorpresa de «este tornillo sobra»). En cambio, si es largo, cualquier error se convierte en una búsqueda: «¿en qué parte de este monolito olvidé poner Content-Type?».

2. Orden de los pasos: desde HttpExchange hasta la respuesta

Ahora vamos a construir el «pipeline» de procesamiento de la solicitud, para tener en la mente una secuencia de acciones estable. Esto es especialmente importante en el mundo pre-Spring: aquí nadie hará por ti el ruteo, binding, validación, mapeo de errores y escritura de respuesta. Si no mantienes el orden de los pasos, terminas leyendo el body antes de entender la ruta, o enviando la respuesta dos veces (y el servidor luego se enfada y cierra la conexión en silencio).

Es útil verlo así: handle(…) es el guion de alto nivel, y los detalles viven en métodos pequeños. Esquemáticamente:

flowchart TD
    A["handle(exchange)"] --> B["registrar method + path"]
    B --> C["route(exchange)"]
    C --> D{rama de ruta}
    D -->|GET /health| E[sendJson 200]
    D -->|POST …| F["readBody -> readJson DTO"]
    F --> G["construir response DTO"]
    G --> H[sendJson status]
    C --> I["si ruta no encontrada -> sendNotFound"]
    C --> J["si error de transporte -> sendBadRequest"]

Para las ramas GET sin body, después de route(…) suele ser suficiente con los helpers de path/query/headers: obtener el id de path, leer filtros de query, verificar la cabecera necesaria y seguir adelante. En las ramas con body, el pipeline solo difiere en un fragmento: antes de readBody(…) hay una compuerta explícita basada en Content-Type, y luego vienen readBody(…), la verificación de body vacío y readJson(…). Es el mismo pipeline del handler, solo que diferentes ramas leen distintas partes de la solicitud entrante.

Ten en cuenta una cosa: el ruteo no debe leer el body «por si acaso». El body se lee solo en las ramas donde se espera. De lo contrario, haces trabajo extra y generas errores: por ejemplo, un GET sin body empieza a fallar con «JSON incorrecto» porque alguien decidió leer el body siempre.

Desde el punto de vista práctico, es importante que el manejador haga cuatro cosas de forma predecible: registrar la entrada, elegir la ruta, analizar los datos de entrada y enviar la respuesta. Todo lo demás, en lo posible, fuera del handler, pero hoy nos mantenemos conscientemente en el nivel de transporte y hacemos solo una «previsualización» de las operaciones, sin la lógica real de la lista de lectura.

3. Esqueleto de ReadingListHandler: dependencias en el constructor

Necesitamos elegir dónde vivirá el manejador y cómo obtendrá las dependencias. En nuestro proyecto, esto ocurre en el paquete com.example.readlater.readinglist.http, porque es claramente la capa web/transporte de la funcionalidad de lista de lectura. Dentro del handler, definitivamente necesitamos ObjectMapper, porque leemos JSON y escribimos JSON. Es importante no crear ObjectMapper dentro de cada solicitud: es lento y rompe la disciplina arquitectónica (además, luego no podrás configurar JSON de forma centralizada).

Comencemos con un esqueleto mínimo de la clase. Todavía no conoce rutas ni helpers, pero ya muestra buenos hábitos de ingeniería: paquete, clase final, Logger, campo de dependencia y constructor.

package com.example.readlater.readinglist.http;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

final class ReadingListHandler implements HttpHandler {
    // Logger — para diagnosticar solicitudes entrantes y problemas de transporte.
    private static final Logger log = LoggerFactory.getLogger(ReadingListHandler.class);

    // ObjectMapper se crea una sola vez en el composition root y se pasa al handler.
    private final ObjectMapper objectMapper;

    ReadingListHandler(ObjectMapper objectMapper) {
        // Una comprobación simple no está de más: de lo contrario, el NPE aparecerá "en algún momento después".
        this.objectMapper = objectMapper;
    }
}

El esqueleto aún no conoce rutas ni helpers, pero los dos hábitos principales ya están en su lugar: las dependencias vienen del exterior y el handler no empieza a ensamblar media aplicación a su alrededor.

Otro detalle: deliberadamente no hago la clase public. En un proyecto pequeño es útil comenzar con package-private, para no convertir cualquier clase en «parte de la API pública de la aplicación». Dentro del paquete readinglist.http nos basta con ver el manejador y sus métodos auxiliares.

4. Método handle(…): guion y manejo de errores

Ahora agreguemos en la misma clase el handle(…) de alto nivel. La versión funcional combina inmediatamente dos cosas: el registro de la entrada y un único punto de interceptación de errores de transporte. De lo contrario, los errores se dispersan muy rápido entre las ramas de ruteo.

@Override
public void handle(HttpExchange exchange) throws IOException {
    // Registramos el mínimo: método + ruta (sin body ni datos sensibles).
    log.info("{} {}", exchange.getRequestMethod(), exchange.getRequestURI().getPath());

    try {
        // Dentro de route(...) ya viven las ramas concretas de ruteo.
        route(exchange);
    } catch (IllegalArgumentException e) {
        // El cliente envió datos incorrectos — esto no es un "error del servidor".
        log.warn("Bad request: {}", e.getMessage());

        // Devolvemos un formato JSON estable de error y un código HTTP correcto.
        sendBadRequest(exchange, e.getMessage());
    }
}

Aquí son importantes dos hábitos de ingeniería. Primero, handle(…) se mantiene breve y no conoce los detalles de las rutas. Segundo, el manejo de IllegalArgumentException vive en un solo lugar y no se dispersa por handleHealth(…), handleCreatePreview(…) y otras ramas.

El formato de ErrorResponse y los helpers JSON de bajo nivel ya los tenemos. El handler en este nivel simplemente los usa y no convierte el bloque catch en un framework artesanal independiente.

5. route(…): todo el ruteo en un solo lugar

route(…) es tu «mini-horario de trenes». Dice: si llega un GET /health — ve allí; si llega un POST … — ve aquí; de lo contrario — «ruta no encontrada». Es tentador repartir esto en diferentes métodos («que cada endpoint se verifique a sí mismo»), pero entonces pierdes la visión completa. Cuando hay un error en el ruteo, quieres abrir un solo método y ver con tus ojos lo que el servidor entiende.

En nuestro nivel actual, basta con un simple if/else sin abstracciones «geniales». Es importante mantener el orden: las reglas más concretas arriba, las más generales abajo. Y aún más importante: hacer un return temprano después de haber enviado la respuesta, para no terminar intentando enviar una segunda respuesta.

import com.sun.net.httpserver.HttpExchange;

import java.io.IOException;

private void route(HttpExchange exchange) throws IOException {
    // Obtenemos el método y la ruta una sola vez, para no repetirnos ni equivocarnos en las condiciones.
    String method = exchange.getRequestMethod();
    String path = exchange.getRequestURI().getPath();

    // La rama más simple, sin body.
    if ("GET".equals(method) && "/health".equals(path)) {
        handleHealth(exchange);
        return; // Importante: la respuesta ya se envió, no seguimos.
    }

    // Rama temporal de transporte: permite probar el pipeline JSON sin mezclarlo con la lógica de negocio.
    if ("POST".equals(method) && "/api/v1/reading-list/preview".equals(path)) {
        handleCreatePreview(exchange);
        return;
    }

    // Si no coincidió ninguna, devolvemos un JSON 404 normal.
    sendNotFound(exchange);
}

Aquí se ven claramente dos ramas diferentes del mismo handler: una vive sin body, la otra requiere body y JSON. Pero el propio route(…) sigue sin leer el body, sin parsear JSON y sin construir la respuesta — solo elige a quién pasarle el control.

6. Métodos auxiliares junto al handler

Cuando escribes el segundo o tercer endpoint, te das cuenta de que repites los mismos fragmentos de código: leer body, parsear JSON, verificar Content-Type, enviar JSON, establecer Content-Type en la respuesta, calcular la longitud de los bytes. Si lo dejas «en su lugar», en una semana tendrás cinco variantes de sendJson(…), y adivina en cuál de ellas olvidarás algún día charset=UTF-8.

A la escala de un solo handler, estos métodos está bien mantenerlos como private junto a route(…). Las clases utilitarias independientes solo serán necesarias si este código comienza a repetirse entre varios manejadores. Para el día de hoy, basta con un conjunto breve de helpers.

Empecemos con la lectura del body. El flujo es de un solo uso, así que lo leemos una sola vez y lo decodificamos inmediatamente a UTF-8:

import com.sun.net.httpserver.HttpExchange;

import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;

private String readBody(HttpExchange exchange) throws IOException {
    // El cuerpo de la solicitud se lee una sola vez: el flujo es de un solo uso.
    try (InputStream is = exchange.getRequestBody()) {
        byte[] bytes = is.readAllBytes();
        return new String(bytes, StandardCharsets.UTF_8);
    }
}

Para las ramas con body, también necesitamos una compuerta explícita basada en Content-Type:

import com.sun.net.httpserver.HttpExchange;

private void requireJsonContentType(HttpExchange exchange) {
    String contentType = exchange.getRequestHeaders().getFirst("Content-Type");

    if (contentType == null || !contentType.startsWith("application/json")) {
        throw new IllegalArgumentException("Se espera Content-Type: application/json");
    }
}

El siguiente helper es la lectura de JSON a DTO. Capturamos la IOException de Jackson y la convertimos en IllegalArgumentException para que el handle(…) superior devuelva 400 Bad Request y no un stack trace al cliente:

import java.io.IOException;

private <T> T readJson(String body, Class<T> type) {
    try {
        // Deserialización al tipo DTO correspondiente.
        return objectMapper.readValue(body, type);
    } catch (IOException e) {
        // A nivel de transporte, es importante devolver 400, no "explotar" con un stack trace.
        throw new IllegalArgumentException("JSON incorrecto");
    }
}

Y finalmente, el envío de JSON. Aquí hay dos detalles «eternos»: Content-Type debe establecerse antes de enviar las cabeceras, y la longitud debe calcularse en bytes.

import com.sun.net.httpserver.HttpExchange;

import java.io.IOException;
import java.io.OutputStream;

private void sendJson(HttpExchange exchange, int status, Object body) throws IOException {
    // Serializamos directamente a bytes: así calculamos correctamente Content-Length.
    byte[] bytes = objectMapper.writeValueAsBytes(body);

    // Las cabeceras se establecen antes de sendResponseHeaders(...), de lo contrario podrían no llegar a la respuesta.
    exchange.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8");
    exchange.sendResponseHeaders(status, bytes.length);

    // Es importante cerrar OutputStream: try-with-resources garantiza el cierre incluso en caso de error de escritura.
    try (OutputStream os = exchange.getResponseBody()) {
        os.write(bytes);
    }
}

Sobre sendJson(…) quedan envoltorios finos para las ramas de error típicas:

import com.example.readlater.common.error.ErrorResponse;

import java.io.IOException;
import java.util.List;

private void sendBadRequest(HttpExchange exchange, String message) throws IOException {
    sendJson(exchange, 400, new ErrorResponse("BAD_REQUEST", message, List.of()));
}

private void sendNotFound(HttpExchange exchange) throws IOException {
    sendJson(exchange, 404, new ErrorResponse("NOT_FOUND", "Ruta no encontrada", List.of()));
}

Como resultado, las ramas del handler se leen como guiones, no como un conjunto de manipulaciones de bajo nivel con flujos y bytes.

7. Endpoint de «preview»: ciclo completo sin lógica

Ahora vamos a crear una miniama que muestre el ciclo completo de procesamiento de la solicitud, pero sin meterse en la lógica de la lista de lectura. Esto es importante: hoy construimos la capa de transporte, no un CRUD. Por lo tanto, el endpoint será temporal: recibirá JSON, extraerá un par de campos y los devolverá como respuesta, para que puedas verificar la mecánica a través de Postman.

Para esta rama no hace falta otro DTO casi igual. Basta con utilizar CreateReadingItemRequest, que ya describe la forma del JSON entrante.

import com.example.readlater.readinglist.dto.CreateReadingItemRequest;

import java.io.IOException;
import java.util.Map;

private void handleCreatePreview(HttpExchange exchange) throws IOException {
    // 1) La rama con body primero verifica el media type.
    requireJsonContentType(exchange);

    // 2) Leemos el body como cadena (una sola vez).
    String body = readBody(exchange);

    // 3) Verificación básica de transporte: body vacío es 400.
    if (body.isBlank()) {
        throw new IllegalArgumentException("El cuerpo de la solicitud es obligatorio");
    }

    // 4) Parseamos el JSON al DTO de solicitud ya conocido.
    CreateReadingItemRequest request = readJson(body, CreateReadingItemRequest.class);

    // 5) Devolvemos una respuesta eco corta: verificamos que el pipeline de transporte está ensamblado.
    sendJson(exchange, 200, Map.of(
            "title", request.title(),
            "author", request.author()
    ));
}

Esta rama de preview no pretende ser un endpoint de creación real. Su única tarea es hacer pasar por el handler el pipeline completo del body y no mezclarlo inmediatamente con el repositorio, el servicio y la lógica de dominio.

Después de esto, todo el esqueleto del handler se lee completo así:

final class ReadingListHandler implements HttpHandler {
    private static final Logger log = LoggerFactory.getLogger(ReadingListHandler.class);
    private final ObjectMapper objectMapper;

    ReadingListHandler(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @Override
    public void handle(HttpExchange exchange) throws IOException {
        log.info("{} {}", exchange.getRequestMethod(), exchange.getRequestURI().getPath());

        try {
            route(exchange);
        } catch (IllegalArgumentException e) {
            log.warn("Bad request: {}", e.getMessage());
            sendBadRequest(exchange, e.getMessage());
        }
    }

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

        if ("GET".equals(method) && "/health".equals(path)) {
            handleHealth(exchange);
            return;
        }

        if ("POST".equals(method) && "/api/v1/reading-list/preview".equals(path)) {
            handleCreatePreview(exchange);
            return;
        }

        sendNotFound(exchange);
    }

    // A continuación quedan los helpers privados:
    // requireJsonContentType(...), readBody(...), readJson(...),
    // sendJson(...), sendBadRequest(...), sendNotFound(...).
}

Este es el baseline de fin de día: un handler breve donde las ramas con y sin body conviven, y la rutina de transporte no se dispersa por el proyecto.

8. Estructura del proyecto: frontera readinglist.http

Ahora es útil fijar que estamos construyendo una capa web, no «escribiendo todo en cualquier lugar». Si el handler empieza a contener reglas de negocio (por ejemplo, «externalId debe ser único»), romperemos silenciosamente la arquitectura del curso: el transporte empezará a gobernar el dominio, y luego todo se desmoronará. Por lo tanto, el handler debe vivir en readinglist.http, los DTO en readinglist.dto, el dominio en readinglist.domain, el servicio en readinglist.service (pero hoy no lo tocamos).

Visualmente, puede verse así:

com.example.readlater
└── readinglist
    ├── dto
    │   └── CreateReadingItemRequest.java
    └── http
        └── ReadingListHandler.java

Por su parte, ErrorResponse es lógico mantenerlo en el common.error general, porque el formato único de error es necesario no solo para una funcionalidad.

Y un detalle pequeño pero importante: ReadingListHandler debe crearse en el composition root (en el modo server), no debe crearse a sí mismo un ObjectMapper, Logger y «media aplicación». Idealmente, el modo server hace algo así: toma el ObjectMapper de un lugar común (common.json), crea el handler y registra el contexto. Esto no es «belleza por belleza», es una forma de mantener las dependencias predecibles.

Por ahora, nuestro manejador contiene los métodos auxiliares dentro de sí mismo; esto es aceptable e incluso bueno para el aprendizaje. Pero si ves que estos métodos se vuelven demasiados, es mejor extraerlos en pequeñas clases helper, manteniéndote siempre dentro del paquete readinglist.http. Es importante no traspasar la frontera y no construir un «framework» que sea más complejo que el objetivo del curso.

9. Errores típicos al ensamblar HttpHandler

Al final del día, es muy tentador «rematar» el manejador hasta dejarlo en un estado «bueno, funciona», pero es precisamente aquí donde los principiantes suelen pisar el rastrillo que luego es difícil de depurar. Los errores no suelen estar en la teoría de HTTP, sino en los detalles: orden de las acciones, envío doble de respuesta, duplicación de utilidades, longitud incorrecta del body. Repasemos algunos escenarios típicos para que los reconozcas por el olor.

Error n.º 1: handle(…) se convierte en un megamétodo que lo hace absolutamente todo.
Al principio parece cómodo: «bueno, veo todo el código en un solo lugar». En la práctica, después de un par de endpoints, obtienes 150 líneas de handle(…) con bifurcaciones, donde parte del código se repite y otra parte depende de variables de otra rama. La solución es una regla simple: handle(…) se lee como un guion de alto nivel, y los detalles van a route(…), handleCreatePreview(…), sendJson(…), etc.

Error n.º 2: crear ObjectMapper dentro de la ruta o dentro de cada solicitud.
Técnicamente puede funcionar, pero pagas con inicialización extra y pierdes un punto único de configuración de JSON. Al final, un endpoint serializa la fecha de una forma, otro de otra, y un tercero falla inesperadamente por un campo desconocido. Mantén ObjectMapper como una dependencia del handler, pasada a través del constructor, y créalo una sola vez en el composition root.

Error n.º 3: leer el request body «por si acaso» antes del ruteo.
Lees el body al principio de handle(…) y luego resulta que es un GET /health que no necesita body. Aunque «no es grave», te estás creando problemas: trabajo extra, posibles bloqueos en la lectura y, sobre todo, pierdes la disciplina de «primero la ruta, luego el parseo». Lee el body solo en las ramas donde se espera.

Error n.º 4: longitud incorrecta de la respuesta en sendResponseHeaders(…).
Una trampa muy común: enviar exchange.sendResponseHeaders(status, json.length()) donde json es una cadena. Para ASCII a veces «funciona por casualidad», pero para UTF-8 (especialmente con texto en español), la longitud en caracteres no es igual a la longitud en bytes, y el cliente puede recibir una respuesta truncada. La solución es simple: serializar a byte[] y usar bytes.length.

Error n.º 5: olvidar establecer Content-Type o establecerlo «después de enviar las cabeceras».
Las cabeceras no son deseos, son parte del contrato. Si Content-Type no se establece, el cliente puede intentar interpretar JSON como text/plain, o viceversa. Y si estableces la cabecera después de sendResponseHeaders(…), es posible que no llegue a la respuesta. Por lo tanto, Content-Type se pone antes de enviar las cabeceras, siempre.

1
Tarea
Java Server, nivel 23, lección 4
Bloqueada
Handler `GreetingHandler` corto con manejo centralizado de errores
Handler `GreetingHandler` corto con manejo centralizado de errores
1
Tarea
Java Server, nivel 23, lección 4
Bloqueada
Corto `EchoHandler` con query y body en un solo lugar
Corto `EchoHandler` con query y body en un solo lugar
1
Cuestionario/control
Enrutamiento HTTP, nivel 23, lección 4
No disponible
Enrutamiento HTTP
Rutas y procesamiento de solicitudes
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION