1. Respuesta HTTP: estado, encabezados, cuerpo
Tan pronto como logramos leer la solicitud y convertir JSON en DTO, surge inmediatamente la segunda mitad de la cadena de transporte: ahora debemos armar la respuesta con el mismo cuidado. Cuando empezamos a escribir un servidor, es muy tentador pensar: «Bueno, ya leí la solicitud, así que ahora simplemente os.write("ok") y listo». Ese es exactamente el punto donde comienzan los errores extraños y las caras sorprendidas de los clientes. En HTTP, la respuesta no es solo texto. Son tres partes independientes, y cada una tiene un significado: el estado indica si la solicitud se procesó correctamente, los encabezados explican qué datos se están enviando y cómo interpretarlos, y el cuerpo contiene los datos (o el error) en el formato acordado.
Es útil tener en mente una tabla simple para no convertir el envío de una respuesta en un rito esotérico:
| Parte de la respuesta | Qué significa | Dónde se define en HttpServer | Ejemplo para nuestra API |
|---|---|---|---|
| Código de estado | «Resultado» del procesamiento desde el punto de vista del protocolo | exchange.sendResponseHeaders(status, length) | 200, 400, 404 |
| Encabezados | Metadatos de la respuesta (incluyendo el formato del cuerpo) | exchange.getResponseHeaders().set(...) | Content-Type: application/json; charset=UTF-8 |
| Cuerpo | Contenido de la respuesta (JSON) | exchange.getResponseBody().write(bytes) | {"status":"UP","appName":"readlater-api"} |
El hábito de ingeniería más común que cultivamos aquí: al cliente no le debe doler leer tu respuesta. Si dijiste «esto es JSON», entonces debe ser JSON. Si enviaste el estado 400, entonces realmente es un error de solicitud, no «solo estoy de mal humor». Todavía no estamos haciendo «diseño REST perfecto», pero debemos cumplir con la honestidad básica del contrato desde ahora.
2. DTO en lugar de construir JSON con cadenas
A nivel de «demostración rápida» se podría construir JSON manualmente con concatenación de cadenas. Pero en código real casi siempre termina en lágrimas: comillas olvidadas, coma de más, caracteres especiales sin escapar, campo de repente null — y el cliente recibe un JSON que no se puede analizar. Si a JSON le gustara el dolor, sería XML (es broma… en parte).
Por lo tanto, hacemos lo mismo que ya hicimos en la entrada: usamos Jackson ObjectMapper y trabajamos con DTO de respuesta (o al menos con estructuras simples como Map). Dentro del proyecto se siente como «estoy devolviendo un objeto», no «estoy escribiendo texto».
Aquí hay un DTO de respuesta mínimo para /health:
package com.example.readlater.app.api;
// DTO que serializaremos a JSON y devolveremos al cliente
public record HealthResponse(String status, String appName) {
}
Y ahora queremos convertir este DTO en JSON. La forma más ordenada es serializar directamente a bytes, no a una cadena. ¿Por qué es importante? Porque sendResponseHeaders(...) pide la longitud del cuerpo, y la longitud en HTTP es en bytes, no en «cantidad de caracteres en la cadena». Con UTF‑8 estos números pueden diferir fácilmente (por ejemplo, los caracteres cirílicos casi siempre ocupan más de 1 byte por carácter).
Mini ejemplo de serialización a bytes:
import com.fasterxml.jackson.databind.ObjectMapper;
// ObjectMapper — el «motor» de Jackson para (des)serializar
ObjectMapper objectMapper = new ObjectMapper();
// Obtenemos bytes directamente: es conveniente para HTTP, donde la longitud del cuerpo está en bytes
byte[] bytes = objectMapper.writeValueAsBytes(
new HealthResponse("UP", "readlater-api")
);
// bytes.length es lo que se necesita para sendResponseHeaders(...),
// porque esa es la cantidad exacta de bytes que realmente escribiremos en el flujo de respuesta
Aquí ya estamos «en el camino correcto»: tenemos un DTO, tenemos JSON en bytes, y solo queda formatear correctamente la respuesta HTTP.
3. Content-Type y UTF‑8
Si imaginamos la respuesta HTTP como un paquete, entonces Content-Type es la etiqueta «Frágil / Cuidado / Qué hay dentro». Sin ella, el cliente adivinará: ¿es JSON? ¿Es texto? ¿Es un archivo binario? En el mundo de los navegadores y Postman a veces «se adivina», y por eso parece que los encabezados no son importantes. Pero es una autoilusión del tipo «mi código funciona porque todavía no lo he roto».
En nuestro curso fijamos una opción simple y clara para las respuestas JSON:
Content-Type: application/json; charset=UTF-8
¿Por qué es útil charset=UTF-8 incluso si «ya se entiende»? Porque queremos previsibilidad. Hoy envías solo latín, mañana comentarios en ruso, pasado mañana el nombre del autor «Gabriel García Márquez» (allí también hay algo que considerar). Le decimos directamente al cliente: «Amigo, esto es UTF‑8, no te imagines cosas».
Y otro detalle muy práctico de HttpServer: los encabezados deben establecerse antes de sendResponseHeaders(...). Después de llamar a sendResponseHeaders(...), el servidor comienza a «fijar» la respuesta, e intentar cambiar los encabezados puede ser ignorado o provocar un comportamiento extraño.
4. Orden de envío de la respuesta JSON
Ahora viene un poco de «ritual», pero bueno: el envío de una respuesta tiene una secuencia estricta de pasos. Si se rompe, aparecen clientes colgados, respuestas vacías y la sensación de que la red es una maldición.
Imaginemos el algoritmo como un pequeño diagrama:
flowchart TD
A[Tenemos un DTO de respuesta] --> B[Serializamos el DTO a bytes mediante ObjectMapper]
B --> C["Establecemos el encabezado Content-Type"]
C --> D["sendResponseHeaders(status, bytes.length)"]
D --> E[Escribimos bytes en el OutputStream del body de respuesta]
E --> F["Cerramos el OutputStream / exchange"]
Parece casi una receta de panqueques, solo que en lugar de panqueques es JSON, y en lugar de «dar la vuelta» es «cerrar el stream» (pero el sentido es el mismo: los pasos son importantes, de lo contrario todo se desmorona).
5. Método sendJson(...)
Cuando escribes el primer manejador, puedes enviar la respuesta «directamente» dentro del método handleHealth(...). Pero tan pronto como aparece un segundo endpoint, de repente notas que estás copiando el mismo código: serialización, encabezado, longitud, stream, close. Ese es el momento en que es útil extraer la mecánica a un pequeño método auxiliar. No un «framework universal», no una «fábrica abstracta de fábricas», sino simplemente una función ordenada que hace una cosa clara.
Imaginemos que dentro de ReadingListHandler (readinglist.http) tenemos un campo objectMapper y agregamos un método privado para enviar JSON:
import com.fasterxml.jackson.databind.ObjectMapper;
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 el objeto de respuesta (DTO / Map / ErrorResponse) a bytes JSON
byte[] bytes = objectMapper.writeValueAsBytes(body);
// Los encabezados deben establecerse ANTES de sendResponseHeaders(...)
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8");
// En HTTP, la longitud del cuerpo se indica en bytes, por lo tanto usamos bytes.length
exchange.sendResponseHeaders(status, bytes.length);
// try-with-resources garantiza el cierre del flujo de respuesta
try (OutputStream os = exchange.getResponseBody()) {
os.write(bytes); // Escribimos exactamente los bytes cuya longitud declaramos arriba
}
}
Observa cuánto código «temible» queda ahora escondido en un solo lugar. Los métodos de ruta se vuelven cortos y legibles. Por ejemplo, /health ahora se ve casi como humano:
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
private void handleHealth(HttpExchange exchange) throws IOException {
// Formamos el DTO de respuesta (sin concatenación manual de cadenas JSON)
HealthResponse dto = new HealthResponse("UP", "readlater-api");
// Devolvemos la respuesta con un estilo uniforme: estado + JSON
sendJson(exchange, 200, dto); // {"status":"UP","appName":"readlater-api"}
}
Aquí hay un detalle importante: el comentario con JSON es una «vista aproximada». Jackson puede cambiar el orden de los campos, y eso es normal. El cliente no debe depender del orden de los campos en JSON (si depende, ese cliente necesita ayuda y un psicólogo… también es broma… en parte).
Otra ventaja práctica de sendJson(...): ahora envías tanto el éxito como el error de la misma manera. Esto da la sensación de que «la API tiene estilo», no «cada endpoint es un universo separado».
6. ErrorResponse también se serializa como un DTO normal
Un error muy común de los principiantes: «bueno, el éxito lo devolvemos como JSON, pero los errores… los escribimos como texto». Eso es conveniente exactamente por un minuto. Luego aparece una colección de Postman, el cliente tiene manejadores de errores, y tú tienes diferentes formatos de error que no se pueden analizar correctamente.
Ya antes en el curso inventamos un contrato de error unificado:
import java.util.List;
// DTO de error unificado: el cliente siempre recibe un JSON del mismo formato
public record ErrorResponse(String errorCode, String message, List<String> details) {
}
Desde el punto de vista del envío de la respuesta, esto no difiere en nada de HealthResponse. Es un objeto igual que se convierte en JSON mediante el mismo sendJson(...).
Por ejemplo, si atrapamos en el límite HTTP una IllegalArgumentException (típico «error de solicitud» en nuestro nivel actual), podemos hacer lo siguiente:
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.util.List;
private void sendBadRequest(HttpExchange exchange, String message) throws IOException {
// Formamos el cuerpo del error como DTO, no como «simple texto»
ErrorResponse body = new ErrorResponse(
"BAD_REQUEST",
message,
List.of() // details se pueden llenar más tarde, cuando aparezcan validaciones
);
// Devolvemos 400 y JSON en el mismo estilo que las respuestas exitosas
sendJson(exchange, 400, body);
}
Exactamente igual se formatea la rama de fallback del enrutamiento. 404 no debe vivir como un caso textual separado: para el cliente es una respuesta JSON con el mismo contrato de error.
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.util.List;
private void sendNotFound(HttpExchange exchange) throws IOException {
ErrorResponse body = new ErrorResponse(
"NOT_FOUND",
"Ruta no encontrada",
List.of()
);
sendJson(exchange, 404, body);
}
Ahora tanto sendBadRequest(...) como sendNotFound(...) son solo envoltorios delgados sobre sendJson(...), no dos «estilos de respuesta» diferentes dentro de la misma API.
Y entonces el código del manejador se vuelve claro: «ocurrió un error de solicitud → devolvemos 400 y JSON con el error». Y el registro (logging), que ya tenemos mediante SLF4J/Logback, sigue siendo una historia aparte: el log es para el desarrollador, ErrorResponse es para el cliente. Si le envías al cliente un stack trace, el cliente no se vuelve más inteligente, pero tú — obtienes un agujero de seguridad adicional. Incluso en un proyecto de aprendizaje no cultivamos ese hábito.
7. Longitud del cuerpo: bytes.length vs json.length()
Ahora viene el momento «detalle por el que puedes perder medio día». El método sendResponseHeaders(status, responseLength) pide la longitud en bytes. Si haces esto:
String json = objectMapper.writeValueAsString(body);
// ❌ json.length() es la cantidad de caracteres, no de bytes (UTF-8 lo complica todo)
exchange.sendResponseHeaders(200, json.length());
entonces con cualquier texto no ASCII (hola, español con acentos) la longitud de caracteres y la longitud de bytes comenzarán a diferir. El cliente puede recibir un JSON truncado, puede quedarse colgado esperando los bytes restantes, puede empezar a quejarse de «Unexpected end of JSON input». Y mirarás el código y pensarás: «Pero… lo envié todo».
Por lo tanto, nuestra forma «DTO → bytes» resuelve el problema de inmediato:
byte[] bytes = objectMapper.writeValueAsBytes(body);
// ✅ bytes.length es la longitud real del cuerpo en bytes, exactamente esa declaramos en los encabezados
exchange.sendResponseHeaders(200, bytes.length);
Sí, es literalmente una línea de diferencia. Pero es como el cinturón de seguridad: parece innecesario justo hasta el primer giro.
8. Respuesta sin cuerpo
Aunque hoy no estamos construyendo un CRUD ni discutiendo estados «perfectos», ya es útil tener la capacidad técnica de enviar una respuesta sin body. Porque a veces una «respuesta vacía» no es un error, sino un contrato perfectamente válido.
En HttpServer existe un acuerdo importante para esto: si pasas la longitud -1, el servidor enviará solo los encabezados y no esperará un cuerpo.
Método pequeño que envía «Sin Contenido» (sin cuerpo):
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
private void sendNoContent(HttpExchange exchange, int status) throws IOException {
// -1 significa: no hay cuerpo, enviamos solo estado + encabezados
exchange.sendResponseHeaders(status, -1);
// No hay cuerpo — no hay nada que escribir, por lo tanto el intercambio se puede cerrar inmediatamente
exchange.close();
}
¿Por qué llamamos a exchange.close()? Porque ya no hay «intercambio»: no hay cuerpo, no hay nada que escribir, se puede cerrar el recurso. Esto evita conexiones «colgadas» y hace el comportamiento más predecible.
Por ahora puedes usar esto como un ladrillo técnico para el futuro. Hoy nuestro mínimo es aprender a enviar JSON de manera estable y entender qué significado tiene cada parte de la respuesta. Pero es bueno que la infraestructura no se rompa si mañana necesitas una respuesta sin cuerpo.
9. Errores típicos en respuestas JSON
Error n.º 1: enviar el body antes de sendResponseHeaders(...).
En HttpExchange no puedes empezar a escribir en getResponseBody() y luego «recordar» que debes establecer el estado y los encabezados. Primero se forma la «cabecera» de la respuesta mediante headers y sendResponseHeaders(...), y solo luego se escribe el cuerpo. Si se invierte el orden, se producen efectos extraños: desde respuestas vacías hasta IOException en el momento menos esperado.
Error n.º 2: olvidar establecer Content-Type para JSON.
Postman a veces «adivina» que es JSON, a veces no. Otros clientes pueden ni siquiera intentar analizar el cuerpo como JSON sin el encabezado. Además, es parte de la disciplina de la API: prometes el formato explícitamente, no con indirectas. Si ya acordaste JSON, entonces Content-Type: application/json; charset=UTF-8 debe estar presente en las respuestas de manera estable.
Error n.º 3: calcular la longitud de la respuesta usando la cadena (json.length()), no los bytes.
Esto es un clásico, especialmente si envías mensajes con caracteres acentuados o detalles de error. En UTF‑8 los caracteres pueden ocupar varios bytes, por lo tanto «cantidad de caracteres» y «cantidad de bytes» son números diferentes. El enfoque correcto es serializar directamente a byte[] y usar bytes.length.
Error n.º 4: no cerrar el OutputStream de la respuesta.
Si no cierras exchange.getResponseBody(), el cliente puede esperar el final de la respuesta mientras tú piensas que «todo se envió». La construcción try (OutputStream os = exchange.getResponseBody()) { ... } aquí es tu mejor amigo. Cierra el flujo de manera garantizada, incluso si ocurre un error dentro.
Error n.º 5: duplicar el mismo código de envío de respuesta en cada ruta.
Hoy tienes 2 rutas, mañana 10, pasado mañana corriges accidentalmente Content-Type en un lugar y lo olvidas en otro. Por lo tanto, un pequeño sendJson(...) no es un «lujo arquitectónico», sino una forma de reducir la probabilidad de errores. Hace que los manejadores sean legibles y concentra la mecánica del protocolo en un solo lugar.
GO TO FULL VERSION