1. Validación de parámetros de consulta y ruta
Cuando empezamos a escribir una API REST, existe la tentación de pensar que la auténtica «entrada» es @RequestBody y que todo lo demás es secundario: total, ¿qué importa ?page= o {taskId} en la ruta? En la práctica ocurre justo lo contrario: los endpoints de listado y los de recursos viven constantemente de parámetros de consulta y ruta, y son precisamente ellos los que con mayor frecuencia rompen los escenarios antes incluso de que aparezca el JSON.
Imaginemos que un cliente llama a GET /api/v1/tasks?page=-1. Desde el punto de vista humano, parece «quiero la página menos uno» — es como pedir en un libro una página que no existe. O una solicitud size=100000: el cliente parece pedirle al servidor «tráeme todas las tareas del mundo, preferiblemente de una vez, y además un café». Aunque por ahora tengamos un almacenamiento in-memory, sigue siendo una entrada incorrecta según el contrato y no debería pasar.
Lo mismo ocurre con taskId en GET /api/v1/tasks/{taskId}. En el proyecto los identificadores son cadenas UUID. Si el cliente envía taskId=123, no es «simplemente no encontrado». Es un formato de identificador con el que no trabajamos en absoluto. Por lo tanto, es una categoría de error «entrada incorrecta», y es mejor detectarlo en el límite, no en el servicio, donde luego comienza un circo de análisis y excepciones confusas.
Es importante mantener una idea simple: los parámetros de consulta y ruta son parte del contrato externo de la API. Si el contrato dice que page no puede ser negativo y size no puede ser mayor que 100, entonces hay que expresarlo directamente donde se declara el parámetro, es decir, en la firma del método del controlador.
2. Binding y validación: dos puertas de entrada
Es muy útil entender que antes de que Spring siquiera invoque el método del controlador, la solicitud pasa por al menos dos capas de «verificación». La primera capa es la vinculación de las cadenas de entrada a tipos Java (binding + conversión de tipos). La segunda capa son las restricciones de Bean Validation que usted ha escrito con anotaciones (@Min, @Max, @Pattern, etc.). Estas dos capas se parecen en el efecto (en ambos casos obtendrá un 400), pero en significado son problemas diferentes.
Si la solicitud llega como ?page=abc, Spring no puede convertir la cadena "abc" en int. Es un error de formato/tipo: el parámetro no se puede convertir. Su método del controlador ni siquiera se ejecuta porque no se pudieron reunir los argumentos.
Si la solicitud llega como ?page=-1, entonces "-1" se convierte perfectamente en int, pero la restricción @Min(0) dice: «el valor del rango permitido ha sido violado». Esto ya no es un error de conversión, sino un error de contrato.
Esquemáticamente, conviene representarlo así:
flowchart LR
%% Primero ocurre la vinculación y conversión de tipos (binding/type conversion)
A[Solicitud HTTP] --> B[Binding + conversión de tipos]
%% Luego se ejecutan las restricciones de Bean Validation sobre parámetros/DTO
B -->|éxito| C[Restricciones Bean Validation]
C -->|éxito| D[Método del controlador]
B -->|error de formato| E[HTTP 400]
C -->|restricción violada| E
La conclusión práctica de este esquema es simple: si mantiene los parámetros como String y los analiza manualmente en el controlador, está desactivando la primera puerta y trasladando esa responsabilidad a sus hombros. Y si no establece restricciones (@Min, @Max, @Pattern), entonces la segunda puerta simplemente no tiene sobre qué apoyarse: valores formalmente correctos (-1, 999999) se cuelan tranquilamente dentro de la aplicación.
Para no convertir el controlador en un mini-analizador y mini-validador al mismo tiempo, es mejor dejar que Spring haga su trabajo: ya sabe convertir tipos y ya sabe ejecutar Bean Validation; solo hay que describir correctamente las restricciones.
3. Validación de parámetros del controlador: no es el mismo mecanismo que @Valid
En este punto muchos principiantes cometen el error clásico: «Ya puse @Valid en @RequestBody, por lo tanto la validación está activada para todo el método». No es exactamente así. @Valid se encarga de la verificación del objeto (DTO) y de la validación en cascada de datos anidados. Pero los parámetros simples del método (int page, String sort, String taskId) llegan al controlador por otro camino: primero se convierten desde la cadena y luego se verifican como parte de la firma del método.
En ejemplos más antiguos encontrará fácilmente @Validated en el controlador. Es útil conocer esta anotación, pero en la línea actual de MVC no hay que percibirla como un interruptor obligatorio para fragmentos básicos de controlador. Aquí es más importante otra diferencia: @Valid se refiere al grafo de objetos, mientras que @Min / @Max / @Pattern sobre @RequestParam y @PathVariable se refieren al contrato de parámetros simples del método.
En el proyecto Task Tracker API, los controladores viven en com.example.tasktracker.api.controller. El esqueleto mínimo de TaskController aquí es el más común:
package com.example.tasktracker.api.controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController // Controlador REST habitual de Spring MVC
@RequestMapping("/api/v1/tasks") // Ruta base para todos los endpoints del controlador
public class TaskController {
}
A continuación, las restricciones se colocan directamente sobre los parámetros de los métodos, y Spring las verifica antes de entrar al método. Y @Valid nos hará falta para argumentos objeto como @RequestBody y @ModelAttribute.
4. Validación de page y size
Los parámetros de paginación son una de las entradas más «explotadas» en una API. Se invocan constantemente, se transmiten manualmente, los transmite la UI, se insertan en enlaces. Y si no se limitan en el límite, luego se encontrarán efectos muy extraños: páginas vacías «del pasado», índices negativos, tamaños de respuesta gigantescos y, en definitiva, usuarios insatisfechos (o su propio navegador insatisfecho).
Mientras los parámetros del endpoint de listado sean pocos, es útil mantenerlos directamente en la firma del método. Así es más fácil ver aisladamente dónde actúan @Min, @Max y defaultValue. Pero esto es solo una forma intermedia: tan pronto como a page, size y sort se añadan filtros, ese método ya pide a gritos un DTO de criterios.
En nuestro contrato Task Tracker API los parámetros son: page — basado en cero, valor por defecto 0; size — valor por defecto 20, máximo 100. Esto significa que las restricciones mínimas razonables se ven así: page >= 0, 1 <= size <= 100. Precisamente esto lo expresaremos mediante @Min y @Max.
Aquí hay un ejemplo de un endpoint de listado donde las restricciones están directamente en la firma del método:
package com.example.tasktracker.api.controller;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class TaskController {
@GetMapping("/api/v1/tasks")
public String list(
@RequestParam(defaultValue = "0") @Min(0) int page, // page: solo 0 y superior
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size // size: 1..100, de lo contrario 400
) {
return "ok";
}
}
Observe dos cosas. Primero, aceptamos page y size como int, no como String. Esto permite que Spring haga la conversión y que nosotros no tengamos que escribir un Integer.parseInt(...) manual ni capturar NumberFormatException en el controlador. Segundo, defaultValue es una cadena, pero debe ser correcta para el tipo y satisfacer las restricciones. Si accidentalmente coloca defaultValue = "-5" y al mismo tiempo @Min(0), literalmente creará un endpoint que se contradice a sí mismo.
Un buen hábito: percibir defaultValue como parte del contrato. No es un «detalle técnico», es el comportamiento público de su API. El cliente puede no pasar el parámetro, y entonces el servidor decide por él. Por lo tanto, esa decisión debe ser válida.
Si desea hacer los parámetros opcionales sin defaultValue y usa Integer, es importante recordar: la mayoría de las restricciones (por ejemplo, @Min) consideran null como un valor válido por defecto. Es decir, @Min(0) Integer page no «prohíbe la ausencia de page». Simplemente dice: «si page está presente, que sea >= 0». Esto es normal, pero entonces en el código hay que manejar cuidadosamente null (o usar @NotNull), de lo contrario obtendrá un NPE donde no quería dedicarse a hacer investigación.
5. Validación de sort y taskId
Validación de sort: lista blanca mediante @Pattern
La ordenación es un parámetro que externamente parece simple: una cadena como updatedAt,desc. Pero precisamente la ordenación suele convertir la API en un «contrato oculto» si no se acuerda el formato y los valores permitidos. El cliente envía sort=priority,sideways, usted piensa «bueno, lo intento», luego alguien añade un nuevo campo al modelo, alguien cambia el nombre, y de repente la ordenación empieza a dar resultados extraños o a fallar.
En nuestro proyecto acordamos el formato field,dir, donde dir es asc o desc, y field pertenece a la lista blanca: createdAt, updatedAt, dueDate, priority, status, title. Este acuerdo se puede expresar directamente en el contrato mediante @Pattern.
Un ejemplo mínimo se ve así:
import jakarta.validation.constraints.Pattern;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@GetMapping("/api/v1/tasks")
public String listBySort(
@RequestParam(defaultValue = "updatedAt,desc")
// Fijamos el contrato: solo los campos listados y solo asc/desc
@Pattern(regexp = "^(createdAt|updatedAt|dueDate|priority|status|title),(asc|desc)$")
String sort
) {
return "ok";
}
La expresión regular aquí no es por «magia de regex», sino por la lista blanca. Dice: «solo se puede ordenar por estos campos y solo en estas direcciones». Esto hace que el contrato sea predecible de inmediato. Es importante que usemos ^ y $ para que la cadena coincida completamente, no «en algún lugar interior hay los caracteres requeridos». Es decir, updatedAt,descPLEASE no pasará, y eso está bien.
Sí, se pueden odiar las expresiones regulares, y tiene todo el derecho moral a hacerlo. Pero en este lugar, la regex no es un intento de resolver un problema de escala mundial, sino una forma sencilla de fijar el formato y la lista blanca en un solo sitio. La alternativa es analizar la cadena manualmente, escribir if/else, inventar mensajes de error y volver a convertir el controlador en un mini-analizador. Y hemos acordado que los controladores son delgados y no les gusta el drama innecesario.
Mientras sort viva como un @RequestParam separado, es conveniente mantener la lista blanca directamente sobre el parámetro. Si más adelante se reúne el mismo contrato de búsqueda en un objeto de criterios, la regla no desaparecerá, simplemente se mudará al campo sort dentro de él.
Validación de taskId: @Pattern para UUID
El parámetro taskId en la ruta (/api/v1/tasks/{taskId}) es la dirección de un recurso concreto. Según la arquitectura del proyecto, el identificador es una cadena UUID. Esto significa que un «taskId válido» no es cualquier cadena, sino una cadena de un formato específico. Y si el cliente envía «no UUID», no es un escenario de «tarea no encontrada». Es un escenario de «ha formulado mal la solicitud».
La forma más directa de fijar esto es colocar @Pattern directamente sobre @PathVariable. Para un proyecto de aprendizaje es suficiente una comprobación simple de «36 caracteres y guiones» para descartar basura evidente:
import jakarta.validation.constraints.Pattern;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/api/v1/tasks/{taskId}")
public String details(
@PathVariable
// Comprobación ligera de formato: descartamos valores que claramente no se parecen
@Pattern(regexp = "^[0-9a-fA-F-]{36}$")
String taskId
) {
return "ok";
}
Sí, esta expresión no verifica todos los detalles de un UUID (por ejemplo, la posición de los guiones), pero demuestra bien el principio: el parámetro de ruta se puede y se debe validar, y se hace con las mismas restricciones que para el cuerpo de la solicitud.
Si se desea hacer la comprobación estricta, puede usar una expresión regular más precisa o incluso aceptar el parámetro como UUID para que Spring haga la conversión de tipos. Pero la idea clave de esta lección no es la «validación perfecta de UUID», sino que el identificador de recurso también es una entrada. Y si no se corresponde con nuestro contrato, la solicitud no debe llegar a la capa de servicio.
Un beneficio adicional agradable: cuando taskId se valida en el límite, el servicio puede trabajar con él como un identificador «normal», sin tener que estar mirando constantemente la posibilidad de que el formato sea «no soy un UUID, he venido a arruinarles el día».
6. Ejemplo de TaskController con parámetros
Ahora vamos a reunir un fragmento general, lo más parecido posible a nuestro proyecto. Para analizar la mecánica, por ahora dejaremos los parámetros por separado: así es más fácil ver cómo en un mismo método conviven page, size, sort y taskId. Pero tan pronto como el GET /api/v1/tasks acumule filtros, el mismo contrato de búsqueda será más cómodo encapsularlo en un TaskSearchCriteria.
Primero mostramos la clase del controlador y las constantes para las expresiones regulares (para no guardar la «alfombra de regex» directamente en los parámetros):
package com.example.tasktracker.api.controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/tasks")
public class TaskController {
// Comprobación simplificada de UUID (versión educativa): «parece UUID por longitud/caracteres»
static final String UUID_LITE = "^[0-9a-fA-F-]{36}$";
// Lista blanca del formato sort: field,dir — solo campos y direcciones permitidas
static final String SORT = "^(createdAt|updatedAt|dueDate|priority|status|title),(asc|desc)$";
}
Ahora el método de listado con page, size, sort:
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Pattern;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@GetMapping
public String list(
@RequestParam(defaultValue = "0") @Min(0) int page, // page: las páginas empiezan en 0
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size, // size: limitamos el tamaño máximo de respuesta
@RequestParam(defaultValue = "updatedAt,desc") @Pattern(regexp = SORT) String sort // sort: solo lista blanca
) {
return "ok";
}
Y el método de detalle con taskId:
import jakarta.validation.constraints.Pattern;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/{taskId}")
public String details(
@PathVariable @Pattern(regexp = UUID_LITE) String taskId // taskId: filtramos basura en el límite de la API
) {
return "ok";
}
Si ahora se invocan los endpoints con parámetros «malos», verá que el método simplemente no se ejecuta. Esto se puede comprobar incluso sin un formateo bonito de errores: ponga un breakpoint en el método y no se activará. Spring interrumpe la solicitud antes, porque la entrada violó el contrato.
Para la autocomprobación es útil tener un par de solicitudes en un archivo .http:
### Conjunto de solicitudes para verificación manual: cada una debe dar 400
### (y por tanto el método del controlador no debe ejecutarse)
### page < 0 -> 400
GET http://localhost:8080/api/v1/tasks?page=-1
Accept: application/json
### size demasiado grande -> 400
GET http://localhost:8080/api/v1/tasks?size=1000
Accept: application/json
### sort no está en la lista blanca -> 400
GET http://localhost:8080/api/v1/tasks?sort=unknownField,asc
Accept: application/json
### taskId no parece UUID -> 400
GET http://localhost:8080/api/v1/tasks/not-a-uuid
Accept: application/json
Y una solicitud adicional «sobre otro tipo de error», donde el validador ni siquiera alcanza a actuar porque el tipo no se convierte:
### page no es un número -> 400 (error de conversión de tipo)
GET http://localhost:8080/api/v1/tasks?page=abc
Accept: application/json
Este es precisamente el caso donde «error de formato» y «restricción violada» se diferencian, pero ambos escenarios se bloquean en el límite y no dejan pasar basura al servicio.
7. Errores típicos en la validación de parámetros
Error n.º 1: pensar que @Valid sobre @RequestBody cubre automáticamente @RequestParam y @PathVariable.
La verificación de un argumento objeto y la verificación de parámetros simples no son el mismo mecanismo. Si page, size, sort y taskId participan en el contrato, las restricciones deben escribirse directamente sobre esos parámetros, no esperar que un @Valid en algún lugar del controlador «ilumine todo de una vez».
Error n.º 2: defaultValue no satisface las restricciones y el endpoint se contradice a sí mismo.
Si ha puesto @RequestParam(defaultValue = "-1") @Min(0) int page, entonces, cuando el cliente no proporcione page, usted mismo está creando una entrada no válida. Parece que «la lógica por defecto del servidor está rota». Compruebe siempre que los valores por defecto caigan dentro del rango.
Error n.º 3: aceptar page/size como String y hacer un análisis manual en el controlador.
Así, el controlador se convierte rápidamente en un «club de amantes de NumberFormatException». Spring ya sabe convertir tipos y lo hace antes. Cuando usted deja String, está renunciando a ese mecanismo y complicando el código sin beneficio.
Error n.º 4: poner una expresión regular demasiado pesada en sort y luego no poder mantenerla.
@Pattern es bueno como lista blanca y como fijación del formato. Pero si la expresión regular se convierte en 200 caracteres sin posibilidad de entender lo que sucede, el contrato se vuelve ilegible. Es mejor mantener el formato simple y explícitamente limitado que intentar cubrir con un solo golpe de regex «todas las variantes posibles de ordenación del mundo».
Error n.º 5: validar taskId «aproximadamente» y luego dentro del servicio igualmente hacer UUID.fromString(taskId) sin protección.
Si eligió la estrategia «taskId es una cadena», entonces trátelo como una cadena que ya ha pasado la validación. Si dentro del servicio desea realmente un UUID, es mejor convertirlo en un solo lugar (y mantener un solo enfoque); de lo contrario, puede darse la situación de que la validación haya dejado pasar un valor y el análisis posterior igualmente haya fallado.
GO TO FULL VERSION