← Peticiones, respuestas y CRUD en memoria

Sesión 5 · Semana 3

De la petición al objeto Java

Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado el primer workflow. En Servidor continúas la implementación del mismo producto.

Se explica

25 minutos · explicación y demostración

El CRUD funciona, pero una petición puede fallar antes de entrar en tu método. Hoy seguirás cómo Spring selecciona la ruta y transforma las entradas. Deserializar significa convertir el JSON recibido en un objeto Java; el registro de ejecución permitirá identificar dónde se detiene ese proceso.

Cómo llega la petición al controlador

En las primeras sesiones asociaste rutas con métodos mediante anotaciones y enviaste objetos JSON. Ahora vas a distinguir tres tareas que realiza Spring antes de invocar tu código: elegir el método, obtener sus parámetros y convertir el cuerpo. Esta distinción permite buscar cada fallo en el lugar adecuado.

El recorrido completo

Cuando llega una petición, no hay un if gigante buscando tu ruta. Hay una cadena de piezas, cada una con un trabajo, y cada una puede rechazar la petición por un motivo distinto.

De los bytes a tu método, y de vuelta
  1. Tomcat acepta la conexión y convierte los bytes en un objeto petición
  2. DispatcherServlet recibe absolutamente todas las peticiones y dirige el tráfico
  3. Handler mapping busca qué método tuyo corresponde a esa ruta y ese método HTTP
  4. Resolutores de argumentos construyen uno a uno los parámetros de tu método
  5. Tu método se ejecuta y devuelve un valor
  6. Conversor de mensaje convierte ese valor en el cuerpo de la respuesta
  7. Tomcat escribe la respuesta en la red

DispatcherServlet

La puerta única. Todas las peticiones de tu aplicación pasan por él, sea cual sea la ruta. Se llama front controller: en lugar de que cada ruta tenga su propio punto de entrada, hay uno solo que reparte. Por eso puedes añadir un endpoint nuevo sin registrarlo en ningún sitio.

Tú nunca escribes esta clase, nunca la instancias y nunca la llamas. La monta @SpringBootApplication al arrancar, y es la razón de que la línea de la consola dijera with context path '/': le está diciendo desde qué prefijo escucha.

Qué puede ser un parámetro de tu método

El paso 4 del recorrido es el que más magia parece. Spring mira uno a uno los parámetros que has declarado y, según cómo estén anotados, sabe de dónde sacar el valor.

Lo que declaras De dónde sale Visto en
@PathVariable Un trozo de la ruta UD1 · sesión 2
@RequestParam La query string UD1 · sesión 2
@RequestBody El cuerpo de la petición UD1 · sesión 3
@RequestHeader Una cabecera Hoy
HttpServletRequest La petición cruda entera Hoy

No hay un orden obligatorio ni un número máximo. Puedes combinarlos todos en un mismo método.

@RequestHeader · leer una cabecera

En la sesión 1 viste que cada petición viaja con una lista de cabeceras. Aquí es donde se recogen:

@GetMapping("/diagnostico")
public String diagnostico(
        @RequestHeader(name = "User-Agent") String cliente,
        @RequestHeader(name = "Accept") String acepta) {

    return "Me llama: " + cliente + "\nQuiere recibir: " + acepta;
}

Pruébalo desde dos clientes distintos: desde Postman y desde el navegador. La ruta es la misma, tu código es el mismo, y la respuesta es distinta, porque quien pregunta no es el mismo.

Es un buen momento para entender algo: el servidor sabe bastante más de quien le llama de lo que parece, y todo eso lo ha enviado el cliente voluntariamente en cada petición.

Cabeceras que no siempre vienen

Si pides una cabecera que no llega, obtienes un 400, igual que con un @RequestParam obligatorio. La corrección es la misma:

@RequestHeader(name = "X-Origen", required = false) String origen

Las cabeceras que empiezan por X- son, por convención, las que se inventa cada aplicación para sus propias necesidades.

La pregunta que quedó sin responder

En la UD1 enviaste un JSON con una errata y la API respondió 200 con el campo a null. Lo anotamos como una curiosidad inquietante y seguimos.

Hoy toca entenderlo, porque es el origen de una clase entera de fallos: los que no fallan. Un error que devuelve 500 te despierta a las tres de la mañana; un error que devuelve 200 guardando datos incompletos no te despierta nunca, y aparece tres meses después cuando alguien pregunta por qué faltan doscientos títulos.

Quién convierte el cuerpo

Conversor de mensaje

La pieza del recorrido HTTP que traduce entre el cuerpo HTTP —bytes y texto— y los objetos Java. Funciona en las dos direcciones: al entrar, con @RequestBody; al salir, con lo que devuelve tu método.

Para JSON, ese conversor utiliza Jackson, la biblioteca de conversión introducida en la sesión 3. En el modelo con constructor vacío y setters que estamos usando, el proceso es el siguiente; otros modelos, como los records, se construyen de otra forma.

Qué hace Jackson con cada clave del JSON
  1. Crea el objeto vacío con el constructor sin argumentos
  2. Coge la primera clave del JSON, por ejemplo titulo
  3. Busca un setter que le corresponda: setTitulo
  4. Si lo encuentra, convierte el valor al tipo que pida ese setter y lo llama
  5. Si no lo encuentra, pasa a la siguiente clave sin decir nada
  6. Repite hasta terminar el JSON

Los pasos 4 y 5 son los que hay que grabar. Jackson recorre el JSON, no tu clase. Lo que no esté en el JSON no se toca, y se queda con el valor por defecto de Java: null para objetos, 0 para números, false para booleanos.

Los tres estados de un cuerpo

Un cuerpo que llega puede estar en tres situaciones muy distintas, y Spring las trata de forma radicalmente diferente:

Estado Ejemplo Qué hace Spring Código
Válido {"titulo":"Revisar"} Construye el objeto 200
Inválido {"titulo":"Revisar",} No puede leerlo, rechaza 400
Incompleto {} Lo construye igual, con valores por defecto 200

La distinción que hay que interiorizar hoy

Inválido es un problema de sintaxis: no es JSON, o no encaja con los tipos. Lo detecta Jackson y produce un 400 automático.

Incompleto es un problema de significado: es JSON perfecto y le faltan datos que tu aplicación necesita. Jackson no tiene ninguna opinión al respecto, porque nadie le ha dicho qué es una tarea válida.

El primero te lo resuelve el framework. El segundo es responsabilidad tuya, y hasta la UD3 no tendrás la herramienta para resolverlo bien.

Se trabaja

140 minutos · implementación guiada sobre el proyecto propio

Paso 1 · Retomar el proyecto y preparar la comprobación

  1. Abre el controlador del CRUD, el modelo y src/main/resources/application.properties. Reproduce un GET y un POST válidos de la sesión 4.
  2. Localiza la consola donde aparecen los mensajes del servidor. Allí leerás los registros, o logs, después de activar el nivel DEBUG en el siguiente paso.
  3. Prepara cuatro variaciones de una petición válida: ruta inexistente, método no admitido, parámetro de tipo incorrecto y cuerpo JSON mal formado. Conserva la petición original para compararlas.

Paso 2 · Activar los logs de Spring y localizar el método que atiende la petición

Añade la propiedad de DEBUG al archivo existente, sin borrar sus otras líneas, y reinicia. Crea una tarea mediante el POST y utiliza su id en el GET: el número 1 del ejemplo solo es válido si ese registro existe. Deja visible la terminal del servidor, envía una petición y localiza únicamente las líneas correspondientes a su ruta; los logs de arranque pertenecen a otro momento.

En src/main/resources/application.properties:

logging.level.org.springframework.web=DEBUG

Desde Postman, un GET http://localhost:8080/tareas/1. Ahora mira la consola: donde antes no salía nada, aparecen varias líneas.

DispatcherServlet        : GET "/tareas/1", parameters={}
RequestMappingHandlerMapping : Mapped to TareaController#detalle(int)
RequestResponseBodyMethodProcessor : Using 'application/json'
DispatcherServlet        : Completed 200 OK
Línea 1 · llegó esto
El DispatcherServlet confirma qué método y qué ruta ha recibido, con sus parámetros. Si aquí no aparece nada, tu petición no llegó a la aplicación: te has equivocado de puerto, o el servidor no está arrancado.
Línea 2 · va a este método
La decisión más importante de todo el recorrido. Te dice, con nombre y apellidos, qué método tuyo va a ejecutarse. Cuando una petición «hace algo raro», esta línea te dice si está entrando por donde crees.
Línea 3 · lo devuelvo así
Con qué formato se va a escribir la respuesta.
Línea 4 · terminó así
El código de estado final.

Esta es la herramienta de diagnóstico de todo el curso

De aquí en adelante, cuando una petición no haga lo que esperas, la primera pregunta ya no es «¿qué pasa?» sino «¿hasta dónde llegó?». Si no aparece la línea 1, no llegó. Si aparece la 1 y no la 2, ninguna ruta encajó. Si aparece la 2 pero el resultado es raro, el problema está en tu método.

Déjalo encendido mientras desarrollas y apágalo cuando te moleste. Es una línea en un archivo.

Paso 3 · Distinguir errores de ruta, método, parámetros y cuerpo

La anotación @PostMapping(consumes=..., produces=...) sustituye a la anotación del POST existente. Conserva dentro del método la asignación de id y el guardado de la sesión 4: el bloque ilustra el acuerdo de formatos, no reinicia el CRUD. consumes restringe el cuerpo que aceptas y produces el formato que puedes devolver; cambia una sola cabecera de la petición en cada prueba.

Dónde muere una petición que no encaja
  1. ¿Hay alguna ruta que coincida? Si no, 404
  2. ¿Esa ruta acepta este método HTTP? Si no, 405
  3. ¿Sabe leer el formato que envío? Si no, 415
  4. ¿Puede devolver el formato que pido? Si no, 406
Código Nombre Qué falló Qué revisas
404 Not Found Ninguna ruta coincide La URL, y el paquete del controlador
405 Method Not Allowed La ruta existe con otro método El verbo de la petición
415 Unsupported Media Type No sabe leer tu Content-Type La cabecera de envío
406 Not Acceptable No puede darte lo que pides en Accept La cabecera de aceptación

Los cuatro son 4xx, y eso ya te lo dice todo

Empiezan por 4, así que ninguno es un fallo del servidor: en los cuatro casos tu método ni siquiera se ha ejecutado. La petición murió por el camino. Buscar el error dentro de tu método es tiempo perdido.

consumes y produces

Puedes declarar en el propio mapeo qué formatos acepta y qué formatos devuelve un método:

@PostMapping(consumes = "application/json", produces = "application/json")
public Tarea crear(@RequestBody Tarea tarea) {
    tareas.add(tarea);
    return tarea;
}

consumes es lo que provoca el 415 cuando el Content-Type no coincide. produces es lo que provoca el 406 cuando lo que pide el cliente en Accept no está entre lo que sabes dar.

Casi nunca hará falta escribirlos: Spring ya deduce lo razonable. Se escriben cuando un mismo recurso puede devolverse en varios formatos, o cuando quieres que el rechazo sea explícito y no una consecuencia.

Paso 4 · Enviar una petición por cada tipo de error y comparar la respuesta

Con el registro encendido y la consola a la vista. Para cada caso, predice el código antes de enviar y después mira hasta qué línea del registro llegó.

# Qué envías Predice
1 GET /tareaas · ruta inexistente
2 DELETE /tareas · sobre la colección, no sobre un elemento
3 POST /tareas con cuerpo y el desplegable de Postman en Text
4 GET /tareas con la cabecera Accept: application/xml

Para el caso 4 tendrás que añadir la cabecera a mano en la pestaña Headers de Postman. Es la primera vez que escribes una cabecera tú.

Al terminar, rellena esta tabla, que es el objetivo real de la sesión:

Caso Código ¿Apareció la línea Mapped to? ¿Se ejecutó tu método?
1
2
3
4

La columna del medio es la interesante. En dos de los cuatro casos se encontró tu método y aun así la petición fue rechazada después. Sabe decir en cuáles y por qué.

Paso 5 · Seguir una petición completa en los logs

Dentro de ProyectoController, añade un método de diagnóstico con @GetMapping("/{id}/incidencias") si la clase ya tiene el prefijo /proyectos. Sus argumentos son @PathVariable(name="id") int id, @RequestParam(name="estado", required=false) String estado y @RequestHeader(name="User-Agent") String cliente. Importa las anotaciones, devuelve una frase con los tres valores y reinicia. Si esa ruta ya existe, utiliza temporalmente /{id}/diagnostico para no sustituir una operación del proyecto. Compara un envío con estado y otro sin él.

  1. Escribe un endpoint GET /proyectos/{id}/incidencias que reciba además un @RequestParam opcional estado y la cabecera User-Agent, y devuelva un texto con los tres valores.
  2. Llámalo desde Postman con todo puesto.
  3. Copia de la consola las cuatro líneas del registro y anota junto a cada una qué pieza del recorrido la ha escrito.
  4. Vuelve a llamarlo quitando el parámetro estado. ¿Cambia alguna línea del registro? ¿Cuál?

Cuerpo JSON y deserialización

Paso 6 · Crear un endpoint de diagnóstico que devuelva el objeto recibido

Para estudiar esto necesitamos ver qué objeto ha construido Jackson. Añade a tu controlador:

@PostMapping("/espejo")
public Tarea espejo(@RequestBody Tarea tarea) {
    System.out.println("He recibido: " + tarea.getTitulo()
            + " / " + tarea.getPrioridad()
            + " / completada=" + tarea.isCompletada());
    return tarea;
}

Devuelve lo que ha construido y además lo imprime, para que veas el objeto Java y el JSON de vuelta a la vez. No guarda nada: es un banco de pruebas.

Paso 7 · Enviar variantes de JSON al endpoint de diagnóstico

Envía estos nueve cuerpos, uno a uno, a POST /tareas/espejo. Predice antes de enviar el código de estado y los valores del objeto.

# Cuerpo enviado Predice el código
1 {"titulo":"Revisar","prioridad":"alta","completada":true}
2 {"titulo":"Revisar"}
3 {}
4 {"titulo":"Revisar",}
5 {"tituloo":"Revisar"}
6 {"titulo":"Revisar","color":"azul"}
7 {"titulo":"Revisar","completada":"quizás"}
8 {"titulo":"Revisar","completada":"true"}
9 cuerpo vacío, sin nada

Los resultados que sorprenden son estos cuatro, y conviene mirarlos despacio:

3 · el objeto vacío
200. Se crea una tarea con título null, prioridad null y completada=false. Tu API acaba de aceptar una tarea que no es nada.
5 y 6 · claves que no existen
200 las dos, y en silencio. Spring Boot configura Jackson para ignorar las claves desconocidas. Resulta indiferente que se trate de una errata propia o de un campo que el cliente se ha inventado: se descarta sin avisar.
7 · un tipo que no convierte
400. "quizás" no es un booleano y Jackson no se lo inventa. Aquí sí protesta, porque es un problema de sintaxis.
8 · un tipo que sí convierte
200, y completada vale true. El texto "true" entre comillas no es un booleano JSON, y aun así Jackson lo acepta y lo convierte. Es tolerante por defecto, y esa tolerancia es una decisión que se puede cambiar.

Paso 8 · Localizar la causa de deserialización en el mensaje de error

Cuando salga un 400, el cuerpo de la respuesta es escueto y casi inútil. El mensaje bueno está, como siempre, en la consola:

HttpMessageNotReadableException: JSON parse error:
Unexpected character ('}' (code 125)): was expecting double-quote to start field name
 at [Source: (line 1, column 26)]

Te dice la excepción, el motivo y la posición exacta. Acostúmbrate a leerla: en la UD3 vamos a convertir estos mensajes en respuestas útiles para el cliente, y no se puede transformar lo que no se sabe leer.

Paso 9 · Configurar el rechazo de campos JSON desconocidos

Que las claves desconocidas se ignoren responde a una configuración que Spring Boot aplica por defecto, no a un comportamiento inherente al protocolo. Puedes darle la vuelta:

spring.jackson.deserialization.fail-on-unknown-properties=true

Reinicia y vuelve a enviar el cuerpo número 6, el del color. Ahora responde 400.

Tolerante · lo que trae Spring

Un cliente antiguo que envía un campo ya retirado sigue funcionando. A cambio, una errata pasa desapercibida y se guarda un dato incompleto.

Estricto · fail-on-unknown-properties

Una errata se detecta al instante. A cambio, cualquier campo de más rompe la petición, y quien te consume tiene que ir exactamente a la par que tú.

Cuál elegir

En una API pública, con clientes que no controlas, se deja tolerante: es preferible ignorar un campo de más a romperle la aplicación a alguien por un cambio tuyo.

En una API interna, o durante el desarrollo, ser estricto ahorra horas de depuración.

Para este curso, déjalo estricto mientras desarrollas la UD2 y la UD3 y decide tú al llegar al proyecto. Lo que no vale es no haberlo decidido.

Paso 10 · Recibir objetos anidados y listas en el JSON

Cada ampliación pertenece a Tarea.java: añade un campo cada vez, sus métodos de acceso y el import de su tipo. Para List<String> importa java.util.List; para LocalDate, java.time.LocalDate. Crea Responsable.java en model con nombre y email, constructor vacío y getters/setters antes de declarar Responsable responsable en Tarea. Después de cada ampliación, reinicia y envía su JSON al endpoint espejo. Esos objetos aún no son relaciones de base de datos.

Una lista dentro del objeto

Añade a Tarea un campo List<String> etiquetas con su getter y su setter, y envía:

{
  "titulo": "Revisar el login",
  "etiquetas": ["urgente", "movil", "regresion"]
}

Jackson construye la lista sola. No hay que hacer nada.

Un objeto dentro del objeto

Crea una clase Responsable con nombre y email, añádela como campo de Tarea, y envía:

{
  "titulo": "Revisar el login",
  "responsable": { "nombre": "Marc", "email": "marc@ejemplo.com" }
}

Jackson entra dentro y repite el mismo proceso con la clase interior. Es recursivo, y por eso funciona con estructuras de cualquier profundidad.

Una fecha

private LocalDate vencimiento;
{ "titulo": "Revisar el login", "vencimiento": "2026-09-15" }

Funciona con el formato ISO, que es año-mes-día con guiones. Prueba a enviar "15/09/2026" y observa el 400: no es que la fecha sea imposible, es que no está en el formato que se espera.

Una fecha siempre se escribe igual

En una API, las fechas se transmiten en formato ISO 8601 —2026-09-15— y no en el formato de ningún país. «15/09/2026» y «09/15/2026» son el mismo texto con dos significados distintos, y el servidor no tiene forma de saber cuál te refieres.

Dar formato a la fecha para que se lea bonita es trabajo del cliente, no tuyo.

Paso 11 · Repetir el diagnóstico con otra entidad de tu dominio

Sobre tu proyecto:

  1. Amplía la clase Proyecto con una lista de String y una fecha.
  2. Crea un endpoint espejo para Proyecto.
  3. Construye tu propia tabla de seis cuerpos: dos válidos, dos inválidos y dos incompletos. Envíalos y anota código y valores resultantes.
  4. Activa fail-on-unknown-properties y repite los seis. Anota cuáles cambian de resultado y cuáles no.
  5. Escribe en dos frases qué configuración dejarías puesta en tu proyecto y por qué.

Paso 12 · Comprobar y registrar el resultado del proyecto

  1. Envía cada variación por separado y relaciona estado, mensaje de la consola y fase de procesamiento. Comprueba si llegó a ejecutarse el método del controlador.
  2. Compara un JSON válido completo, uno con una clave equivocada y otro mal formado. Documenta cuándo hay rechazo y cuándo se construye un objeto con valores por defecto.

Ampliación si has completado el trabajo

Primero termina y verifica los pasos anteriores. Estos retos profundizan en el mismo contenido; no sustituyen la entrega ni obligan a iniciar otro proyecto.

Reto · El endpoint que nunca se ejecuta

Un compañero tiene esto y jura que /tareas/nueva le devuelve 404:

@RestController
@RequestMapping("/tareas")
public class TareaController {

    @GetMapping("/{id}")
    public String detalle(@PathVariable(name = "id") int id) {
        return "Tarea " + id;
    }

    @GetMapping("/nueva")
    public String nueva() {
        return "Formulario de tarea";
    }
}
  1. Antes de tocar nada: ¿le devuelve realmente un 404? Predice qué pasa y por qué.
  2. Reprodúcelo en tu proyecto y mira la línea Mapped to del registro. ¿A qué método está entrando?
  3. El código de estado que sale no es el que tu compañero dice. ¿Cuál es y qué significa?
  4. Explica en dos frases por qué ocurre, usando la palabra «específica».
  5. Propón dos soluciones distintas y di cuál preferirías en una API de verdad.
Objetivo mínimoEl registro encendido, las cuatro líneas identificadas y los cuatro errores provocados.
Si lo tienesEl endpoint con ruta, parámetro y cabecera funcionando, con su traza anotada.
RetoEl diagnóstico completo del endpoint que no se ejecuta, con las dos soluciones comparadas.
Ver respuestas

1 · Tomcat, DispatcherServlet, búsqueda del método, resolución de los argumentos, tu método, conversión del valor devuelto y escritura de la respuesta.

2 · Ninguna ruta ha coincidido con esa petición: la URL no es la que crees, o el controlador no lo ve el escaneo de componentes. El resultado será un 404.

3 · El 415 es sobre lo que envías: el servidor no sabe leer ese Content-Type. El 406 es sobre lo que pides: el servidor no sabe producir el formato de tu Accept.

4 · Porque los cuatro se deciden en fases anteriores a la ejecución. Cuando tu método arranca, ya se ha comprobado que la ruta existe, que el método HTTP encaja y que los formatos son compatibles.

Reto · El campo que desaparece

Sin ejecutarlo todavía, predice qué devuelve este endpoint espejo al recibir el cuerpo de abajo. Escribe el JSON de respuesta entero, clave por clave:

public class Incidencia {

    private int id;
    private String titulo;
    private String estado;
    private boolean urgente;

    public Incidencia() {
    }

    public int getId() { return id; }
    public void setId(int id) { this.id = id; }

    public String getTitulo() { return titulo; }
    public void setTitulo(String titulo) { this.titulo = titulo; }

    public String getEstado() { return estado; }
    // sin setter de estado

    public void setUrgente(boolean urgente) { this.urgente = urgente; }
    // sin getter de urgente
}
{
  "id": 7,
  "titulo": "Caída del servidor",
  "estado": "abierta",
  "urgente": true,
  "prioridad": 3
}

Preguntas:

  1. ¿Qué código de estado devuelve?
  2. ¿Qué valor tiene estado dentro del objeto Java? ¿Y en el JSON de respuesta?
  3. ¿Qué valor tiene urgente dentro del objeto Java? ¿Y en el JSON de respuesta?
  4. ¿Qué ha pasado con prioridad?
  5. Hay un campo que entra y no sale, y otro que no entra y podría salir. Identifícalos y explica la regla que lo provoca.

Cuando lo tengas escrito, cópialo al proyecto y compruébalo.

Objetivo mínimoEl endpoint espejo funcionando y los nueve cuerpos enviados con su resultado anotado.
Si lo tienesEl espejo de proyectos con lista y fecha, y la comparación con y sin tolerancia.
RetoEl JSON de respuesta de Incidencia predicho entero y la regla de los dos campos explicada.
Ver respuestas

1 · Las claves del JSON. La consecuencia es que lo que no venga en el cuerpo no se toca y conserva el valor por defecto de Java, sin que nadie avise.

2 · Porque el primero es JSON perfectamente válido que simplemente no trae datos, y el segundo no es JSON: falla al leerlo, antes de intentar construir nada.

3 · En ISO 8601, 2026-09-15. Porque los formatos nacionales son ambiguos entre sí y el servidor no puede adivinar cuál usa quien llama. Formatearla es trabajo del cliente.

4 · Entra, porque para deserializar Jackson usa los setters. No sale, porque para serializar usa los getters y no hay ninguno.

Cierre

15 minutos · resultado comprobable y explicación individual

Al terminar la sesión:

Debe ser posible explicar de dónde procede cada argumento y conservar peticiones que reproduzcan los tres casos.

Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.