Post

Validar requests con Spring Validation

Veremos varias anotaciones de spring-boot-starter-validation y como validar la información que llega a nuestros servicios antes de ejecutar procesos 👽

AnotaciónDescripción
@NotNullVerifica que un objeto no sea nulo
@NotEmptyVerifica que el objeto no sea nulo y no esté vacío
@NotBlankVerifica que un objeto no sea nulo y no contenga solo caracteres en blanco
@SizeVerifica que un String o Iterable tenga tamaños límite
@MaxVerifica que el número sea >= (Mayor igual) al indicado
@MinVerifica que el número sea =< (Menor igual) al indicado
@PatternVerifica que el objeto coincida con un regex
@PastVerifica que la fecha sea menor a la actual
@FutureVerifica que la fecha sea mayor a la actual
@DigitsAsegura que el valor numérico del campo tenga un número específico de dígitos enteros y fraccionarios
@DecimalMinAsegura que el valor decimal sea >= (Mayor igual) al especificado.
@DecimalMaxAsegura que el valor decimal sea =< (Menor igual) al especificado.
@AssertTrueAsegura que el valor sea true.
@AssertFalseAsegura que el valor sea siempre false.
@PositiveAsegura el que valor sea positivo.
@PositiveOrZeroAsegura el que valor sea positivo o cero.
@NegativeAsegura el que valor sea negativo.
@NegativeOrZeroAsegura el que valor sea negativo o cero.
@URLAsegura que el valor sea un URL válida.
@NegativeAsegura el que valor sea negativo.

Para usar estas anotaciones y validar la información, seguiremos los siguientes pasos:

  • Agregar la dependencia spring-boot-starter-validation en nuestro pom.xml
1
2
3
4
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
  • Debemos anotar con @Valid el dto.
1
2
3
4
5
@DeleteMapping
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> delete(@RequestBody @Valid final QuotaCodRequestDto quotaCodRequestDto) {
    return this.quotaService.delete(quotaCodRequestDto);
}
  • Si queremos validar @PathVariable o @RequestParam, debemos agregar la anotación @Validated en nuestro controlador
1
2
3
4
@Validated
public class QuotaController {
  ...
}

Para cada anotación podemos indicar un mensaje de error en caso de que la validación no se cumpla

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public record QuotaFilterRequestDto(
        @Size(max = 50, message = "El campo description tiene un m\u00e1ximo de {max} caracteres")
        String description,

        Boolean finished,

        @NotNull(message = "El campo pago es obligatorio")
        @Min(value = 0, message = "El campo pageNo tiene un mínimo de {value}")
        Integer pageNo,

        @NotNull(message = "El campo pageSize es obligatorio")
        @Min(value = 1, message = "El campo pageSize tiene un mínimo de {value}")
        @Max(value = 10, message = "El campo pageSize tiene un máximo de {value}")
        Integer pageSize
) {
}

Podemos usar placeholders {} en los mensajes de error, esto facilita el que si tenemos que cambiar algún valor de la validación, no nos preocupemos por cambiar en el mensaje también. 🤖


Si queremos capturar el mensaje de error para devolverlo como respuesta en el api, lo haremos con un @RestControllerAdvice

1
2
public record ErrorMessageResponseDto(String message, Instant timestamp) {
}

Usaremos este dto para devolver información sobre los errores de validación.

  • Capturar errores de @RequestBody + @Valid
1
2
3
4
5
6
7
8
9
10
@RestControllerAdvice
public class GlobalExceptionHandler {
  @ExceptionHandler(MethodArgumentNotValidException.class)
  public ResponseEntity<ErrorMessageResponseDto> handleMethodValidationExceptions(final MethodArgumentNotValidException ex) {
    final String errorMessage = ex.getBindingResult().getAllErrors().stream().findFirst()
      .map(DefaultMessageSourceResolvable::getDefaultMessage).orElse("Validation error unexpected");

    return new ResponseEntity<>(new ErrorMessageResponseDto(errorMessage, Instant.now()), HttpStatus.UNPROCESSABLE_ENTITY);
  }
}
  • Capturar errores de @RequestParam, @PathVariable + @Validated
1
2
3
4
5
6
7
8
9
10
@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ErrorMessageResponseDto> handleConstraintViolationExceptions(final ConstraintViolationException ex) {
        final String errorMessage = ex.getConstraintViolations().stream().findFirst()
                .map(ConstraintViolation::getMessage).orElse("Validation error unexpected");

        return new ResponseEntity<>(new ErrorMessageResponseDto(errorMessage, Instant.now()), HttpStatus.UNPROCESSABLE_ENTITY);
    }
}

Al validar @RequestBody + @Valid la excepción es del tipo MethodArgumentNotValidException. Y si validamos @RequestParam, @PathVariable + @Validated la excepción es ConstraintViolationException 💡


Por defecto @Valid únicamente verifica y valida los campos de primer nivel del dto.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class AdminRegisterRequestDto implements Serializable {
  @NotBlank(message = "El nombre es obligatorio")
  @Pattern(regexp = "^[a-zA-Z\\s]{5,100}$", message = "El nombre solo permite letras")
  private String name;

  @NotBlank(message = "El apellido es obligatorio")
  @Pattern(regexp = "^[a-zA-Z\\s]{5,100}$", message = "El apellido solo permite letras")
  private String lastName;

  @NotNull(message = "Los datos de usuario son obligatorios")
  private UsuarioRegisterDto user;
}

Por ejemplo, aquí únicamente se validarán los campos name y lastName, si el dto UsuarioRegisterDto también tiene validaciones no se tomarán en cuenta.

Para validar dto’s anidados debemos anotarlos con @Valid, esto garantiza que se validen los campos de ese dto.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class AdminRegisterRequestDto implements Serializable {
  @NotBlank(message = "El nombre es obligatorio")
  @Pattern(regexp = "^[a-zA-Z\\s]{5,100}$", message = "El nombre solo permite letras")
  private String name;

  @NotBlank(message = "El apellido es obligatorio")
  @Pattern(regexp = "^[a-zA-Z\\s]{5,100}$", message = "El apellido solo permite letras")
  private String lastName;

  @Valid
  @NotNull(message = "Los datos de usuario son obligatorios")
  private UsuarioRegisterDto user;
}

¿Por qué validar la información de nuestros servicios? 🤔

  • Garantiza que se trabaje con información limpia, evitando errores inesperados.
  • Optimiza el uso de recursos, ya que si los datos no pasan la validación nunca llega a ejecutar lógica interna (Consultar base de datos, llamar a servicios terceros, etc.)
  • Informamos los errores de manera detallada siguiendo estándares y buenas prácticas.

Observaciones

  • No todas las anotaciones se pueden usar con todos los tipos de datos. Por ejemplo si tratamos de validar con @Past un Integer obtendremos una excepción
1
jakarta.validation.UnexpectedTypeException: HV000030: No validator could be found for constraint 'jakarta.validation.constraints.Past' validating type 'java.lang.Integer'. Check configuration for 'pageNo'
This post is licensed under CC BY 4.0 by the author.