CodeGym /Cursos /Java Server /Cadena HTTP de solo lectura sin copia y pega

Cadena HTTP de solo lectura sin copia y pega

Java Server
Nivel 24, Lección 4
Disponible

1. Cadena de procesamiento y copia y pega

Cuando escribes una API sin framework, la primera versión funcional casi siempre sale como «en un manual de supervivencia»: muchos if, muchas líneas repetidas, mucho trabajo manual con HttpExchange. Esto es normal en la etapa de “probar una idea”, pero muy pronto se vuelve difícil de mantener. Es importante detenerse a tiempo y organizar el código de manera que puedas agregar nuevas rutas sin clonar las mismas 12 líneas una y otra vez.

Siendo sinceros, la razón principal por la que a la gente le gusta tanto Spring MVC no son las anotaciones. Es que él se encarga de la rutina aburrida: «poner el encabezado», «serializar el objeto», «cerrar el stream», «registrar el estado final», «gestionar errores similares». Hoy no vamos a inventar Spring, pero daremos un pequeño paso: extraeremos las operaciones técnicas repetitivas en métodos claros, para que la lógica de lectura sea visible como en la palma de la mano.

Y esto no solo es por estética. Cuando la parte de solo lectura está ensamblada así, luego no tienes que reinventar el enrutamiento, el helper JSON y el mapeo cada vez que un recurso tiene una nueva operación.

Para sentir el problema, aquí hay un mini-fragmento de «cómo se suele empezar» (no es “malo”, es “primer enfoque”):

// Le decimos al cliente que devolvemos JSON
exchange.getResponseHeaders().add("Content-Type", "application/json");

// Serializamos el objeto en bytes (lo que realmente irá a la red)
byte[] bytes = mapper.writeValueAsBytes(response);

// Enviamos el estado y la longitud del cuerpo (para HttpServer es parte obligatoria del protocolo)
exchange.sendResponseHeaders(200, bytes.length);

// Escribimos el cuerpo de la respuesta en el flujo
exchange.getResponseBody().write(bytes);

Estas 4 líneas querrás insertarlas literalmente en cada rama. Y luego agregar try-with-resources, luego registro, luego manejo de errores… y en tu manejador, en lugar de significado, tendrás una alfombra de detalles idénticos. Por eso, a continuación, armaremos el código de modo que en el handler se lea “qué hacemos”, no “cómo exactamente escribimos bytes en el OutputStream”.

2. Mapa de rutas de la API de solo lectura

Antes de refactorizar, es útil hacer un pequeño “mapa del terreno”: qué rutas soportamos realmente y qué estados debemos diferenciar. Esto ayuda a no perder el comportamiento durante la limpieza y a no convertir la refactorización en un concurso creativo de «quién encuentra más formas de romper el código que ayer funcionaba». Recopilamos solo la parte de solo lectura, por lo que tenemos solo dos ramas: colección y un recurso.

A continuación, una tabla compacta que es útil tener en mente (y en el README del proyecto, si te gusta el orden):

Método Ruta Escenario Éxito Errores que diferenciamos
GET /api/v1/reading-list lista (filtro opcional) 200 400 (status no válido)
GET /api/v1/reading-list/{id} un elemento por id 200 400 (id no es un número o ≤ 0),
404
(no hay elemento)

Aquí no desplegamos deliberadamente todo el zoo de bifurcaciones HTTP. Para la construcción de solo lectura, nos basta con distinguir honestamente las rutas conocidas, el id incorrecto, el status incorrecto y la ausencia del recurso.

Y esta es la “cadena” de procesamiento que queremos montar de forma legible:

flowchart TD
  A[HttpExchange] --> B[ReadingListHttpHandler]
  B --> C[ReadingListService]
  C --> D[ReadingListRepository]
  D --> E["Map<Long, ReadingListItem>"]
  C --> F["Response DTO"]
  B --> G["sendJson -> JSON bytes"]

El sentido es: el handler se encarga de las decisiones HTTP (ruta, estado), el service se encarga del significado aplicado (obtener datos, filtrar, ensamblar DTO de respuesta), el repository se encarga del almacenamiento/acceso a los objetos de dominio. La serialización JSON y la escritura de la respuesta son parte técnica, intentaremos que sea común y breve.

3. Ayudante de respuestas JSON: un solo sendJson

Si tuviéramos un framework, simplemente devolveríamos un objeto y “en algún lugar” se convertiría en JSON. En HttpServer no funciona así: el objeto no se convierte en JSON por la fuerza del pensamiento. Por eso, surge rápidamente la tentación de escribir lo mismo en cada if: writeValueAsBytes, Content-Type, sendResponseHeaders, getResponseBody. Lo haremos mejor: crearemos un único método que haga esto de la misma manera para todos los endpoints.

Lo más importante de este helper: debe ser pequeño, obvio y no convertirse en un «framework universal». No estamos construyendo una abstracción “para siempre”, solo dejamos de multiplicar el copia y pega.

La implementación mínima de sendJson podría verse así (muestro la parte que normalmente se duplica):

import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;

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

public static void sendJson(HttpExchange exchange, int status, Object body, ObjectMapper mapper) throws IOException {
    // 1) Convertimos el objeto de respuesta en bytes JSON (lugar único de serialización)
    byte[] bytes = mapper.writeValueAsBytes(body);

    // 2) Establecemos Content-Type una sola vez (set, no add — para no acumular encabezados duplicados)
    exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");

    // 3) Enviamos el estado y la longitud del cuerpo (HttpServer lo espera)
    exchange.sendResponseHeaders(status, bytes.length);

    // 4) Escribimos el cuerpo y cerramos OutputStream de forma garantizada
    try (OutputStream os = exchange.getResponseBody()) {
        os.write(bytes);
    }
}

Observa dos detalles. Primero, uso set, no add, para no acumular 15 Content-Type idénticos en los encabezados en intentos repetidos. Segundo, try ( OutputStream … ) garantiza que el flujo se cierre y que el exchange finalice correctamente. Esa es esa capa aburrida pero vital para que “funcione de manera estable”.

Junto a él, es útil tener una pequeña fábrica de errores. Así el handler opera no con constructores, sino con significados preparados: invalidId, itemNotFound, invalidStatus, routeNotFound.

import com.example.readlater.common.error.ErrorResponse;
import java.util.List;

public class ErrorResponses {

    public static ErrorResponse invalidId(String rawId) {
        return new ErrorResponse(
                "INVALID_ID",
                "Id must be a positive number",
                List.of(rawId)
        );
    }

    public static ErrorResponse itemNotFound(long id) {
        return new ErrorResponse(
                "ITEM_NOT_FOUND",
                "Reading list item not found",
                List.of(String.valueOf(id))
        );
    }

    public static ErrorResponse invalidStatus(String rawStatus) {
        return new ErrorResponse(
                "INVALID_STATUS",
                "Unknown status. Allowed: PLANNED, IN_PROGRESS, FINISHED",
                List.of(String.valueOf(rawStatus))
        );
    }

    public static ErrorResponse routeNotFound(String path) {
        return new ErrorResponse(
                "ROUTE_NOT_FOUND",
                "Route not found",
                List.of(path)
        );
    }
}

El sentido no es “ocultar todo”, sino que el lector del código vea en el handler palabras como INVALID_ID y ITEM_NOT_FOUND, en lugar de un interminable new ErrorResponse(...). El cerebro ama el significado, no los paréntesis.

4. Handler HTTP delgado: enrutamiento y delegación

Montar una cadena de solo lectura sin duplicación significa hacer que el manejador de solicitudes sea lo más corto y predecible posible. La imagen ideal es esta: el método handle() dentro de HttpHandler no debe convertirse en una novela de 200 líneas. Debe determinar rápidamente el escenario (list o get-by-id) y delegar el trabajo a métodos separados que ya saben qué estados devolver y qué parámetros leer.

Un punto importante: no construimos un “enrutador universal”, no usamos reflection, no inventamos anotaciones. Nos mantenemos en el estilo del curso: dos if están bien, si cada if lleva rápidamente a un método claro como handleList(...) o handleGetById(...).

handle(): punto de entrada

handle() es el punto de entrada para cada solicitud en este context. En este nivel leemos method y path, normalizamos la ruta (por ejemplo, eliminamos la barra inclinada final sobrante), elegimos la rama y en un solo lugar hacemos el registro del estado final. Esto se parece a un despachador: no hace el trabajo él mismo, dirige.

Uno de los trucos útiles contra la duplicación de registros es que handleList y handleGetById devuelvan el status code que enviaron. Así, handle() siempre puede escribir un solo registro al final. Ejemplo:

import com.sun.net.httpserver.HttpExchange;

import java.io.IOException;

public void handle(HttpExchange exchange) throws IOException {
    // Tomamos el tiempo de procesamiento de la solicitud
    long startedAt = System.nanoTime();

    // Enrutamos la solicitud y obtenemos el estado HTTP final (se envía más abajo en la cadena)
    int status = route(exchange);

    // Convertimos nanosegundos a milisegundos para los logs
    long tookMs = (System.nanoTime() - startedAt) / 1_000_000;

    // Un log por solicitud: método + ruta + estado + tiempo
    log.info("method={}, path={}, status={}, tookMs={}",
            exchange.getRequestMethod(), exchange.getRequestURI().getPath(), status, tookMs);
}

Aquí, route(exchange) es tu pequeño método que elegirá la rama y dentro llamará a sendJson(...). Sí, es un «mini-despachador manual», pero es tan pequeño que no da vergüenza tenerlo en el proyecto. Lo mejor: el log siempre es uno y siempre en el mismo formato.

Así es como podría verse el propio route:

private int route(HttpExchange exchange) throws IOException {
    // Obtenemos el método y la ruta (con normalización de / al final)
    String method = exchange.getRequestMethod();
    String path = normalizePath(exchange.getRequestURI().getPath());

    // Dos ramas de la API de solo lectura: lista y un elemento
    if ("GET".equals(method) && "/api/v1/reading-list".equals(path)) return handleList(exchange);
    if ("GET".equals(method) && path.startsWith("/api/v1/reading-list/")) return handleGetById(exchange, path);

    // Ruta desconocida — 404 estable en formato JSON único
    sendJson(exchange, 404, ErrorResponses.routeNotFound(path), mapper);
    return 404;
}

Para esta construcción, un honesto 404 para una ruta desconocida ya es suficiente. El sentido aquí no es enumerar todas las bifurcaciones HTTP posibles, sino que la cadena de solo lectura esté ensamblada y sea predecible.

Y sí: normalizePath es una pequeña función que te ahorra una hora de vida analizando “por qué /reading-list funciona pero /reading-list/ no”.

private String normalizePath(String path) {
    // Eliminamos la barra final (excepto raíz "/") para que las rutas coincidan de manera predecible
    return (path.endsWith("/") && path.length() > 1) ? path.substring(0, path.length() - 1) : path;
}

handleList(): lista y filtros

La rama de la lista es el lugar donde el código suele empezar a desparramarse: necesitamos leer los query params, pasarlos cuidadosamente al servicio, detectar el “status incorrecto” y devolver 400, pero sin convertir el método en una «procesadora de cadenas». Un buen handleList sigue siendo corto y hace exactamente tres cosas: obtiene los filtros, llama al servicio, elige el estado.

Supongamos que queryParams(...) ya devuelve un Map<String, String> decodificado.

import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.util.Map;

private int handleList(HttpExchange exchange) throws IOException {
    Map<String, String> params = queryParams(exchange);

    // Obtenemos los filtros de la query string (?status=...&title=...)
    String statusParam = params.get("status");
    String titleParam = params.get("title");

    try {
        // El servicio realiza el trabajo de aplicación: filtrado + mapeo + conteo
        sendJson(exchange, 200, service.getFiltered(statusParam, titleParam), mapper);
        return 200;
    } catch (IllegalArgumentException e) {
        // Estado incorrecto (por ejemplo, enum.valueOf no pudo analizar)
        sendJson(exchange, 400, ErrorResponses.invalidStatus(statusParam), mapper);
        return 400;
    }
}

Sí, aquí hay un IllegalArgumentException. Y sí, alguien dirá: “las excepciones para el control de flujo son una mala práctica”. En un mundo ideal, haríamos una validación cuidadosa de los parámetros antes del valueOf(), pero en el contexto de un curso puente es una compensación honesta: es claro, corto y, lo más importante, el comportamiento es estable. No ocultamos el problema, lo convertimos cuidadosamente en un 400 Bad Request.

Otro punto importante: una lista vacía no es un error. Incluso si los filtros lo eliminan todo, sigue siendo 200 OK con items: [], count: 0. Nada de “404 porque no se encontró nada”. 404 es para un recurso por id, no para una colección vacía.

handleGetById(): un elemento

La rama de “un elemento” parece simple, pero es educativamente importante: aquí aprendemos a diferenciar tres estados. abc, 0 o -1 en la ruta no es “el recurso no existe”, es “la solicitud es incorrecta”, por lo tanto 400. Un id positivo correcto no es garantía de que el objeto exista; si el objeto no existe, es 404. Y solo si el elemento se encuentra, es 200.

Esta rama tiene un pequeño helper compartido: parseIdOrThrow(...). Él mantiene la regla abc / 0 / -1 => 400, y después de un parse/validate exitoso se puede resolver la cuestión del 404.

private long parseIdOrThrow(String rawId) {
    long id = Long.parseLong(rawId);

    if (id <= 0) {
        throw new IllegalArgumentException("id must be positive");
    }

    return id;
}

Lo ensamblamos en un método compacto:

import com.example.readlater.readinglist.dto.ReadingItemResponse;
import com.sun.net.httpserver.HttpExchange;

import java.io.IOException;

private int handleGetById(HttpExchange exchange, String path) throws IOException {
    // Extraemos el valor "crudo" del id de la ruta
    String rawId = path.substring("/api/v1/reading-list/".length());

    long id;
    try {
        // 1) Intentamos analizar id -> long y validamos valor positivo
        id = parseIdOrThrow(rawId);
    } catch (IllegalArgumentException e) {
        sendJson(exchange, 400, ErrorResponses.invalidId(rawId), mapper);
        return 400;
    }

    // 2) Id correcto, pero no hay elemento -> 404
    ReadingItemResponse item = service.getById(id).orElse(null);
    if (item == null) {
        sendJson(exchange, 404, ErrorResponses.itemNotFound(id), mapper);
        return 404;
    }

    // 3) Elemento encontrado -> 200
    sendJson(exchange, 200, item, mapper);
    return 200;
}

Aquí se ve deliberadamente que el servicio devuelve un DTO de respuesta, y la decisión HTTP permanece en el handler. Esta es una disciplina muy útil: el servicio no se “contagia” de HTTP, y el handler no se “contagia” de lógica de dominio. Colaboran, pero no se mezclan en una sola masa.

5. Servicio y mapeo: un solo ReadingListService

Cuando implementas tanto list como get-by-id, aparece una trampa desagradable: escribir dos veces el mapeo de ReadingListItem a ReadingItemResponse. Una vez para la lista, otra vez para un elemento. Luego agregarás un campo comment o cambiarás el formato, olvidarás actualizar uno de los mapeos y obtendrás una “API que vive dos vidas diferentes”. Por eso, una estrategia sensata es mantener el mapeo en un solo lugar y reutilizarlo en ambos escenarios.

El servicio aquí es uno solo, y eso es fundamental. La lista, un elemento y los filtros no deben vivir en tres clases casi idénticas. ReadingListService solo conoce el dominio, el DTO y las reglas de filtrado. No sabe nada sobre HttpExchange ni códigos de estado.

Ejemplo del esqueleto del servicio y del mapeo en sí:

import com.example.readlater.readinglist.domain.ReadingListItem;
import com.example.readlater.readinglist.dto.ReadingItemResponse;
import com.example.readlater.readinglist.repository.ReadingListRepository;

public class ReadingListService {
    private final ReadingListRepository repository;

    public ReadingListService(ReadingListRepository repository) {
        this.repository = repository;
    }

    private ReadingItemResponse toResponse(ReadingListItem item) {
        // Lugar único donde el modelo de dominio se convierte en DTO de respuesta
        return new ReadingItemResponse(
                item.getId(),
                item.getTitle(),
                item.getAuthor(),
                item.getStatus(),
                item.getExternalId(),
                item.getComment()
        );
    }
}

Y aquí está getById(...) dentro de la misma clase:

import java.util.Optional;

public Optional<ReadingItemResponse> getById(long id) {
    // El repositorio devuelve el modelo de dominio (o null si no se encuentra)
    return Optional.ofNullable(repository.findById(id))
            // El servicio solo decide "cómo armar el DTO", pero no toma decisiones HTTP
            .map(this::toResponse);
}

Y la respuesta de colección con filtros, en el mismo servicio, sin nuevas versiones «ligeras» de helpers:

import com.example.readlater.readinglist.domain.ReadingStatus;
import com.example.readlater.readinglist.dto.ReadingItemResponse;
import com.example.readlater.readinglist.dto.ReadingListResponse;
import java.util.List;
import java.util.Locale;

public ReadingListResponse getAll() {
    return getFiltered(null, null);
}

public ReadingListResponse getFiltered(String rawStatus, String rawTitle) {
    String statusValue = normalizeBlank(rawStatus);
    String titleValue = normalizeBlank(rawTitle);

    ReadingStatus status = (statusValue == null) ? null : parseStatus(statusValue);

    List<ReadingItemResponse> items = repository.findAll().stream()
            .filter(it -> status == null || it.getStatus() == status)
            .filter(it -> titleValue == null || matchesTitle(it.getTitle(), titleValue))
            .map(this::toResponse)
            .toList();

    return new ReadingListResponse(items, items.size());
}

private String normalizeBlank(String value) {
    if (value == null || value.isBlank()) return null;
    return value.trim();
}

private ReadingStatus parseStatus(String rawStatus) {
    return ReadingStatus.valueOf(rawStatus.trim().toUpperCase(Locale.ROOT));
}

private boolean matchesTitle(String title, String titlePart) {
    return title.toLowerCase(Locale.ROOT)
            .contains(titlePart.toLowerCase(Locale.ROOT));
}

Aquí no aparece ninguna versión «ligera» de filtros. Un parámetro en blanco sigue significando «sin filtro», status se normaliza con trim + mayúsculas, title se busca sin distinguir mayúsculas/minúsculas, y count se calcula sobre la lista ya filtrada. Por lo tanto, GET /api/v1/reading-list, ?status=finished y ?title= java pasan por la misma ruta de código.

Cuando el esqueleto está organizado así, la parte de solo lectura deja de ser un callejón sin salida: el enrutamiento, sendJson(...), el mapeo y los filtros ya están montados de forma tranquila, y las nuevas operaciones no te obligarán a reescribirlos desde cero.

6. Registros mínimos: method, path, status

Cuando escribes una API local, los logs son tu único “rayos X”, porque no vas a usar el depurador en escenarios reales para cada problema. E incluso si lo hicieras, los logs siguen siendo necesarios porque muestran la vida de la aplicación sin tu intervención. La buena noticia: para una API de solo lectura necesitamos una línea base muy pequeña: método, ruta, estado, tiempo de procesamiento. Todo, esto ya es suficiente para empezar a pensar como un desarrollador backend.

El truco clave que ya aplicamos arriba: que handle() registre después del enrutamiento, y el log sea siempre uno solo. Así no esparces logger.info(...) por diez ramas, no lo olvidas en la mitad de los casos y no obtienes “un endpoint escribe tookMs, otro no”.

Ejemplo de “mini-log ideal”:

// Tomamos el inicio (reloj monotónico)
long startedAt = System.nanoTime();

// Hacemos todo el trabajo y obtenemos el estado final (importante: el estado es exactamente el que se envió al cliente)
int status = route(exchange);

// Calculamos la duración
long tookMs = (System.nanoTime() - startedAt) / 1_000_000;

// Registramos en una línea para que los logs sean predecibles
log.info("method={}, path={}, status={}, tookMs={}",
        exchange.getRequestMethod(),
        exchange.getRequestURI().getPath(),
        status,
        tookMs);

Si quieres un poco más de utilidad, puedes agregar la query string para las solicitudes de lista. Pero aquí es importante no caer en registrar “todo lo que se mueve”, o tu API local hará tanto ruido como si estuviera sirviendo a la mitad de Internet. En el contexto del curso, es mejor mantener los logs cortos y predecibles: una línea por solicitud, más warn separados solo para parámetros incorrectos.

Una solicitud — una línea de log. Para una API local, eso ya es una línea base operativa muy digna.

7. Errores típicos en la compilación final de la cadena de solo lectura

Error n.º 1: el handler se convierte nuevamente en un “método todopoderoso”.
Es muy fácil empezar a “escribir un poco más” directamente dentro de handle(): filtrar aquí, mapear allá, calcular count acá. Al final, obtienes un método monolítico de decenas de líneas donde se mezclan detalles HTTP, lógica de dominio y JSON. Mantén en mente la regla: handle() elige la rama, y el significado vive en el servicio.

Error n.º 2: duplicación del envío JSON en cada rama.
Si vuelves a escribir en cada if el bloque de mapper.writeValueAsBytes + Content-Type + sendResponseHeaders, entonces no es refactorización, es «copiar → pegar, pero con más cuidado». Un solo sendJson(...) no es “belleza”, es la garantía de que todas las respuestas se comportan igual y de que no olvidarás cerrar el stream en una de las ramas.

Error n.º 3: olvidaste cerrar exchange.getResponseBody().
Esto es algo traicionero: localmente puede “funcionar”, y luego de repente empezar a comportarse de forma extraña (colgamientos, respuestas incompletas). try-with-resources alrededor de OutputStream es un pequeño hábito que ahorra muchos nervios. Si te parece “innecesario”, recuerda: los frameworks existen precisamente para que no tengas que pensar en esto.

Error n.º 4: confundiste 400 y 404 para id.
Cuando la ruta es /api/v1/reading-list/abc, /api/v1/reading-list/0 o /api/v1/reading-list/-1, no es “recurso no encontrado”, es “solicitud incorrecta”. Si devuelves 404 en todos los casos, el cliente no puede distinguir “me equivoqué en el formato del id” de “ese id no existe”. Para una API, esto es una gran diferencia, incluso si el proyecto es educativo.

Error n.º 5: devolviste 404 porque la lista está vacía.
Una colección vacía es una respuesta normal. La API no debe dramatizar la ausencia de elementos. GET /api/v1/reading-list existe y funciona, incluso si no hay nada que leer. Por lo tanto, 200 OK + items: [] + count: 0 es precisamente un comportamiento “maduro”.

1
Tarea
Java Server, nivel 24, lección 4
Bloqueada
Cadena HTTP de solo lectura sin duplicación de respuestas JSON
Cadena HTTP de solo lectura sin duplicación de respuestas JSON
1
Tarea
Java Server, nivel 24, lección 4
Bloqueada
Registro de ruta y estado final para API read-only
Registro de ruta y estado final para API read-only
1
Cuestionario/control
API de solo lectura, nivel 24, lección 4
No disponible
API de solo lectura
Repositorios, DTO y estados
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION