← APIs REST: recursos, DTO, validación y errores

Sesión 12 · Semana 6

Validar las entradas del CRUD

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

Se explica

25 minutos · explicación y demostración

Un JSON puede tener sintaxis correcta y aun así contener datos que tu aplicación no acepta. Bean Validation permite declarar restricciones, como un texto obligatorio, sobre los DTO. @Valid pide a Spring que compruebe esas restricciones al recibir la entrada.

El formulario del cliente no es una defensa

Un cliente bien hecho comprueba los datos antes de enviarlos: marca en rojo el campo vacío y no deja pulsar el botón. Es buena práctica y mejora la experiencia.

Y no protege absolutamente de nada, porque esa comprobación vive en la máquina de quien la quiera saltar. Con lo que ya sabes hacer desde la UD1, cualquiera puede abrir Postman y enviar el cuerpo que le dé la gana directamente a tu servidor, sin pasar por ninguna interfaz.

Por dónde puede llegar una petición a tu API
  1. Desde el formulario de la aplicación, con sus comprobaciones
  2. Desde Postman, sin ninguna
  3. Desde un script de otra persona, sin ninguna
  4. Desde alguien que quiere ver qué pasa si envía basura

La regla que no se negocia

La validación del cliente es comodidad. La validación del servidor es la única que existe.

Que estén las dos no es duplicar trabajo: una evita un viaje innecesario y mejora la experiencia; la otra protege los datos. Si solo puedes tener una, es la del servidor.

Valida antes de ejecutar, como todo lo demás

Es el mismo mecanismo que ya has visto dos veces: en la UD1 con un @RequestParam que faltaba, y en la UD2 con un cuerpo que no se podía leer.

Dónde encaja la validación en el recorrido
  1. Se busca el método que atiende la ruta
  2. Jackson convierte el cuerpo en el DTO de entrada
  3. Se comprueban las restricciones del DTO
  4. Si alguna falla, 400 y aquí se acaba
  5. Si todas pasan, se ejecuta tu método

La consecuencia práctica es la buena noticia del día: dentro de tu método ya no hace falta comprobar nada de esto. Si el código se está ejecutando, el título no está vacío y la prioridad es una de las tres. No escribas if (titulo == null) en el controlador: eso ya está resuelto antes, y en un solo sitio.

Se trabaja

140 minutos · implementación guiada sobre el proyecto propio

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

  1. Abre pom.xml, el DTO de entrada y los métodos del controlador que lo reciben. Repite una creación válida de la sesión 11.
  2. Anota las restricciones de cada campo: obligatorio, longitud, rango o formato. Distingue esas condiciones de reglas que necesitan consultar otros registros.
  3. Prepara un JSON correcto y copias que incumplan una sola restricción cada vez. Así podrás atribuir cada rechazo a una causa concreta.

Paso 2 · Instala la dependencia, porque no viene puesta

Esto sorprende a mucha gente: la validación no está incluida en spring-boot-starter-web. Hay que pedirla.

En el pom.xml, dentro de <dependencies>:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Sin versión: la pone el padre, como aprendiste en la UD1. Recarga las dependencias de Maven en tu IDE antes de seguir.

Si las anotaciones no hacen nada, es esto

El síntoma es exacto y desconcertante: el proyecto compila, las anotaciones no dan error, la aplicación arranca y la validación se ignora por completo. Un cuerpo vacío se sigue aceptando.

Cuando eso pase, lo primero que se mira es si la dependencia está en el pom.xml y si Maven la ha descargado.

Paso 3 · Las reglas van en el DTO de entrada

Con Maven sincronizado, abre el DTO de entrada existente y añade las anotaciones e imports del bloque a sus campos. No sustituyas toda la clase por una versión sin constructor o métodos de acceso. @NotNull rechaza la ausencia, mientras que @Positive comprueba el valor cuando existe; juntas expresan que proyectoId es obligatorio y positivo. Guarda antes de cambiar el controlador.

En el modelo · no

El modelo lo construye también tu propio código, con datos que ya son válidos. Validar ahí mezcla las reglas del contrato exterior con las de tu dominio.

En el DTO de entrada · sí

Es exactamente la frontera: lo que llega de fuera y no es de fiar. Las reglas quedan junto a la clase que declara qué acepta la API.

package com.ejemplo.gestor.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;

public class TareaRequest {

    @NotBlank
    @Size(min = 3, max = 120)
    private String titulo;

    @NotNull
    @Pattern(regexp = "baja|media|alta")
    private String prioridad;

    @NotNull
    @Positive
    private Integer proyectoId;

    // constructor vacío, getters y setters
}

Fíjate en el paquete: jakarta.validation, no javax. Si el autocompletado te ofrece javax, es de una versión anterior y no funcionará con Spring Boot 3.

Las restricciones que cubren casi todo

Anotación Comprueba
@NotNull Que el campo llegue, aunque venga vacío
@NotBlank Que llegue y no sea vacío ni solo espacios. Solo para texto
@NotEmpty Que llegue y no esté vacío. Para texto y colecciones
@Size(min, max) Longitud de un texto o tamaño de una colección
@Min / @Max Valor mínimo y máximo de un número
@Positive Que un número sea mayor que cero
@Email Que el texto tenga forma de correo
@Pattern(regexp) Que el texto encaje con una expresión regular
@Past / @Future Que una fecha sea anterior o posterior a hoy

Las tres que se confunden siempre

Valor recibido @NotNull @NotEmpty @NotBlank
Campo ausente, o null Falla Falla Falla
"" Pasa Falla Falla
" " Pasa Pasa Falla
"hola" Pasa Pasa Pasa

Para un texto obligatorio quieres casi siempre @NotBlank: es la única que impide que alguien registre una tarea titulada con tres espacios.

Otra razón para los tipos envoltorio

Poner @NotNull sobre un int no sirve de nada: un primitivo nunca es nulo, así que un campo ausente llega como 0 y la validación pasa.

Ya usabas Integer y Boolean en los DTO por lo del PATCH. Esta es la segunda razón, y es igual de importante.

Paso 4 · Activarla · @Valid

En el POST y PUT de TareaController, añade el import jakarta.validation.Valid y coloca @Valid delante del argumento @RequestBody. Conserva el cuerpo del método que ya asigna id, convierte y guarda; los puntos suspensivos de un esquema no son código Java. Reinicia y envía una entrada inválida. Después consulta el listado para verificar que el rechazo ocurrió antes del guardado.

@PostMapping
public ResponseEntity<TareaResponse> crear(@Valid @RequestBody TareaRequest peticion) {
    // Conserva aquí el cuerpo de tu método actual; solo añadimos @Valid.
}

Una palabra. jakarta.validation.Valid.

POST /tareas
{ "titulo": "", "prioridad": "urgentísima" }
{
  "timestamp": "2026-09-02T10:14:22.831+00:00",
  "status": 400,
  "error": "Bad Request",
  "path": "/tareas"
}

400, y la tarea no se ha creado. Mira además la consola: ahí sí está el detalle completo, con el campo y la restricción que ha fallado.

Paso 5 · El problema que queda para la sesión 13

Vuelve a mirar ese 400. Ponte en el lugar de quien consume tu API:

{ "timestamp": "...", "status": 400, "error": "Bad Request", "path": "/tareas" }

¿Qué campo estaba mal? ¿El título? ¿La prioridad? ¿Los dos? ¿Qué valores acepta prioridad?

No hay forma de saberlo. La información existe —está entera en tu consola— y no se la estás dando a quien la necesita. Un cliente que recibe esto solo puede probar a ciegas.

Eso es la sesión 13. Hoy la validación ya protege tus datos; el mensaje todavía no ayuda a nadie.

Validar parámetros que no van en el cuerpo

Las restricciones también valen sobre un @RequestParam o un @PathVariable, pero hace falta un paso más: anotar la clase del controlador con @Validated, de org.springframework.validation.annotation.

Sin esa anotación de clase, un @Positive sobre un parámetro se ignora sin avisar. Es otro caso de «no da error y no hace nada», así que conviene reconocerlo.

Paso 6 · La tabla de rechazos

Con @Valid puesto, envía estos cuerpos a POST /tareas y predice antes el código y el motivo:

# Cuerpo Predice
1 {"titulo":"Revisar el login","prioridad":"alta","proyectoId":7}
2 {}
3 {"titulo":"","prioridad":"alta","proyectoId":7}
4 {"titulo":" ","prioridad":"alta","proyectoId":7}
5 {"titulo":"ab","prioridad":"alta","proyectoId":7}
6 {"titulo":"Revisar","prioridad":"URGENTE","proyectoId":7}
7 {"titulo":"Revisar","prioridad":"alta","proyectoId":-3}
8 {"titulo":"Revisar","prioridad":"alta"}
9 {"titulo":"Revisar","prioridad":"alta","proyectoId":"siete"}

Las dos interesantes son la 4 y la 9:

  • La 4 solo la caza @NotBlank. Con @NotEmpty habrías creado una tarea titulada con tres espacios.
  • La 9 devuelve 400 igual que las demás, pero por otro motivo completamente distinto: falla antes, al convertir el JSON, porque "siete" no es un número. Ni siquiera llega a validarse. Compruébalo en la consola: la excepción no es la misma.

Paso 7 · Las reglas de proyectos

  1. Añade restricciones a ProyectoRequest: el nombre obligatorio y entre 3 y 80 caracteres, la descripción opcional pero como máximo 500.
  2. Pon @Valid en el POST y en el PUT.
  3. Decide qué hacer con ProyectoPatchRequest y justifícalo: si un campo es opcional en un PATCH, ¿puede llevar @NotBlank? ¿Qué pasaría si lo lleva?
  4. Añade a la colección tres peticiones de rechazo con su comprobación de 400.

La pregunta 3 es la que se piensa. Un PATCH recibe campos ausentes por definición, así que @NotNull ahí sería un error. Pero @Size sí tiene sentido: si llega, que tenga la longitud correcta.

Paso 8 · Comprobar y registrar el resultado del proyecto

  1. Ejecuta la tabla de rechazos y verifica que cada entrada inválida produce 400 sin crear ni modificar el registro.
  2. Repite una entrada válida después de los rechazos. Debe seguir funcionando; comprueba que la validación se activa en todos los métodos que reciben el DTO.

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 · Escribe el contrato de validación

Antes de tocar más código, escribe la tabla de reglas de todos los recursos de tu API. Este es el formato:

Recurso Campo Obligatorio en crear Reglas ¿Modificable?

Después responde:

  1. Hay reglas que no se pueden expresar con las anotaciones de hoy. Por ejemplo: «el proyectoId debe corresponder a un proyecto que exista», o «la fecha de fin no puede ser anterior a la de inicio». Localiza al menos dos en tu API y explica por qué se les resisten.
  2. Para cada una de esas dos, ¿dónde tendría que comprobarse entonces? Piensa en quién sabe la respuesta: ¿la clase del DTO, o algo que tenga acceso a la lista de proyectos?
  3. Tu API acepta hoy una tarea de un proyecto inexistente. ¿Es eso un 400 o un 404? Argumenta las dos posturas y quédate con una.

La pregunta 1 abre el trabajo siguiente, y la 2 apunta a la UD4. La 3 no tiene respuesta única y se defiende.

Objetivo mínimoLa dependencia instalada, TareaRequest validado y la tabla de nueve rechazos comprobada.
Si lo tienesProyectos validados, con la decisión sobre el PATCH justificada y tres rechazos en la colección.
RetoEl contrato de validación completo y las dos reglas que las anotaciones no alcanzan, identificadas y situadas.
Ver respuestas

1 · Porque se ejecuta en la máquina de quien la quiera saltar. Cualquiera puede enviar el cuerpo directamente al servidor sin pasar por la interfaz.

2 · Que spring-boot-starter-validation esté en el pom.xml y descargado. Sin esa dependencia todo compila y las anotaciones se ignoran en silencio. Después, que el parámetro lleve @Valid.

3 · Un texto de solo espacios, como " ": no está vacío, pero no contiene nada útil.

4 · Porque un primitivo no puede ser nulo: un campo ausente llega como 0 y la comprobación pasa. Hay que declararlo como Integer.

Cierre

15 minutos · resultado comprobable y explicación individual

Al terminar la sesión:

Las entradas inválidas no crean ni modifican recursos y el cliente recibe una respuesta que puede interpretar.

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