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.
- Tomcat acepta la conexión y convierte los bytes en un objeto petición
- DispatcherServlet recibe absolutamente todas las peticiones y dirige el tráfico
- Handler mapping busca qué método tuyo corresponde a esa ruta y ese método HTTP
- Resolutores de argumentos construyen uno a uno los parámetros de tu método
- Tu método se ejecuta y devuelve un valor
- Conversor de mensaje convierte ese valor en el cuerpo de la respuesta
- 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.
- Crea el objeto vacío con el constructor sin argumentos
- Coge la primera clave del JSON, por ejemplo
titulo - Busca un setter que le corresponda:
setTitulo - Si lo encuentra, convierte el valor al tipo que pida ese setter y lo llama
- Si no lo encuentra, pasa a la siguiente clave sin decir nada
- 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
- 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. - 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.
- 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.
- ¿Hay alguna ruta que coincida? Si no, 404
- ¿Esa ruta acepta este método HTTP? Si no, 405
- ¿Sabe leer el formato que envío? Si no, 415
- ¿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 sí 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.
- Escribe un endpoint
GET /proyectos/{id}/incidenciasque reciba además un@RequestParamopcionalestadoy la cabeceraUser-Agent, y devuelva un texto con los tres valores. - Llámalo desde Postman con todo puesto.
- Copia de la consola las cuatro líneas del registro y anota junto a cada una qué pieza del recorrido la ha escrito.
- 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ítulonull, prioridadnullycompletada=false. Tu API acaba de aceptar una tarea que no es nada.- 5 y 6 · claves que no existen
200las 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, ycompletadavaletrue. 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:
- Amplía la clase
Proyectocon una lista deStringy una fecha. - Crea un endpoint espejo para
Proyecto. - 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.
- Activa
fail-on-unknown-propertiesy repite los seis. Anota cuáles cambian de resultado y cuáles no. - 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
- 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.
- 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";
}
}
- Antes de tocar nada: ¿le devuelve realmente un
404? Predice qué pasa y por qué. - Reprodúcelo en tu proyecto y mira la línea
Mapped todel registro. ¿A qué método está entrando? - El código de estado que sale no es el que tu compañero dice. ¿Cuál es y qué significa?
- Explica en dos frases por qué ocurre, usando la palabra «específica».
- Propón dos soluciones distintas y di cuál preferirías en una API de verdad.
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:
- ¿Qué código de estado devuelve?
- ¿Qué valor tiene
estadodentro del objeto Java? ¿Y en el JSON de respuesta? - ¿Qué valor tiene
urgentedentro del objeto Java? ¿Y en el JSON de respuesta? - ¿Qué ha pasado con
prioridad? - 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.
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.