Crear un validador personalizado en Spring

Spring Boot - Spring Custom Validator

En el post anterior vimos cómo utilizar las validaciones y algunos de los validadores que ya hay implementados que no son pocos y cubren las validaciones más típicas que podemos necesitar, y si queremos algo más específico siempre podemos crear nuestro propio validador.

Aquí vamos a ver cómo podemos crear un validador personalizado en Spring mediante un par de ejemplos que realizaremos sobre el proyecto de Spring Boot que vengo utilizando de base para los temas relacionados con Spring. En el primero vamos a ver cómo crear un validador para el código del producto, algo sencillo, simplemente para ver cómo crear y utilizar validador personalizado y en el segundo crearemos un validador a nivel de clase para validar combinaciones de varias propiedades, en este caso para comprobar que el precio por el número de unidades mínimas supera un límite.

Empecemos…

¿Cómo crear un validador personalizado en Spring?

Para crear nuestro Custom Validator en Spring necesitamos por un lado crearnos nuestra propia anotación para poder añadírsela a nuestras clases del mismo modo que los validadores que ya existen y por otro lado crear el propio validador que no es más que una clase que tiene que implementar la interface ConstraintValidator y que va a ser donde vamos a definir la lógica de nuestro validador.

Empecemos pues por crear nuestra anotación creando un @interface como el siguiente:


package com.programandoointentandolo.tsb.validator;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import javax.validation.Constraint;
import javax.validation.Payload;

/**
 * Comprueba que la suma de los caracteres del codigo este dentro del rango valido
 */

@Documented
@Constraint(validatedBy = ProductoCodigoValidator.class)
@Target({ ElementType.METHOD, ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
public @interface ProductoCodigo {
    String message() default "{productoCodigoValido.mensajePorDefecto}";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}  

Una vez visto el código vamos con las explicaciones de que es y para qué sirve cada cosa, empecemos por las anotaciones:

  • @Documented: Esta anotación simplemente sirve para que cuando generemos el Javadoc de nuestro proyecto se incluya la anotación en las clases, métodos o atributos en las que se usa, si no se incluye en la documentación no veremos que se está utilizando la anotación.
  • @Constraint: Con esta estamos indicando que esta anotación va a ser para un validador y por lo tanto hay que indicar la clase que implementará el validador.
  • @Target: Indica donde podemos utilizar la anotación, la de este ejemplo la podemos utilizar en métodos y atributos.
  • @Retention: Nos dice cuando tiene que estar disponible la anotación, para un validador lógicamente necesitamos que esté disponible en tiempo de ejecución.

Y ahora vamos con los métodos:

  • String message() default "{productoCodigoValido.mensajePorDefecto}";: Aquí definimos el mensaje de error que se mostrará por defecto si no lo redefinimos para la clase en la que usemos el validador. Se puede poner el mensaje directamente o podemos definir un key en cuyo caso tendremos que crear el archivo ValidationMessages.properties en src/main/resources (junto con nuestros messages.properties).
  • Class[] groups() default {};: Sirve para indicar los grupos para los que se utilizará el validador, es la forma que tenemos por ejemplo para que una anotación solo se utilice cuando estamos creando un objeto o cuando lo estamos editando o cuando viene desde un origen concreto que queremos que actúe de forma distinta. Para no mezclar cosas de momento lo dejamos tal cual porque no vamos a tener ningún grupo por defecto, y lo dejo para un post distinto.
  • Class[] payload() default {};: Y lo último que tenemos es el payload() para poder pasarle datos al validador.

Vale, ya tenemos creada nuestra anotación, ahora necesitamos implementar el validador, y al igual que la anotación es también muy sencillo. Simplemente tenemos que crear una clase (la que pusimos en la anotación @Constraint(validatedBy = ProductoCodigoValidator.class)) y hacer que implemente la interface ConstraintValidator<anotacion, objetoValidar>.


package com.programandoointentandolo.tsb.validator;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

public class ProductoCodigoValidator implements ConstraintValidator<ProductoCodigo, String> {

    private static final int MAX = 500;
    private static final int MIN = 250;

    @Override
    public boolean isValid(String codigo, ConstraintValidatorContext context) {
        int suma = codigo.chars().sum();

        return suma > MIN && suma < MAX;
    }

}

El único metodo que necesitamos implementar es isValid() que recibe como parámetro el objeto que queremos validar y un ConstraintValidatorContext que si no necesitamos obtener información del validador no vamos a necesitar usar.

Para este ejemplo de validador personalizado vamos a suponer que el código del producto tiene como restricción que la suma de los valores decimales correspondientes a cada uno de sus caracteres tiene que estar dentro de un rango, por ejemplo entre 250 y 500. Como referencia los valores ASCII permitidos para el código están entre el 48 correspondiente al 0 y el 90 que es el de la Z (porque ya tiene otros validadores), por lo tanto no serán válidos por ejemplo el código A125 o el AZX34ZZZ.

Lo último que nos queda para terminar el validador es añadir el mensaje al archivo ValidationMessages.properties, y en caso de que no lo tuviésemos creado previamente lo creamos en src/main/resources.


productoCodigoValido.mensajePorDefecto = Código no valido

Ahora ya si tenemos listo nuestro validador personalizado para poder usarlo donde queramos, solo nos queda añadir la nueva anotación a la propiedad de la clase que queremos validar para que se haga la validación donde corresponda, en nuestra app de ejemplo en el controlador de Spring MVC.


package com.programandoointentandolo.tsb.entity;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;

import com.programandoointentandolo.tsb.validator.ProductoCodigo;

@Entity
public class Producto {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    @ProductoCodigo
    @Size(min = 3, max = 8)
    @Pattern(regexp = "[A-Z0-9]+")
    private String codigo;

    @NotEmpty
    private String nombre;

    @NotNull
    @Min(2)
    @Max(100)
    private Double precio;
    
    @NotNull
    @Min(1)
    private Integer unidadesMinimas;

    public Producto() {

    }

    // Getters y Setters

}

¿Cómo crear un validador de clase personalizado en Spring?

Con el ejemplo anterior hemos visto como crear un validador para una propiedad, ahora vamos a ver cómo hacer un validador para validar una clase para poder validar varios de sus atributos de forma unificada, para poder validar que se cumplen ciertos criterios que no podemos validar de forma individual como por ejemplo podría ser comprobar que una fecha de fin sea posterior a una fecha de inicio, que los típicos «repite tu password o email» de verdad tengan el mismo valor, etc.

Para que el validador se pueda utilizar a nivel de clase el cambio que hay que hacer respecto al ejemplo anterior simplemente es cambiar el @Target para que incluya ElementType.TYPE.


package com.programandoointentandolo.tsb.validator;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import javax.validation.Constraint;
import javax.validation.Payload;

/**
 * Comprueba que el precio total para el numero minimo de unidades sea mayor de 10
 */

@Documented
@Constraint(validatedBy = ProductoPrecioUnidadesMinimoValidator.class)
@Target({ ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface ProductoPrecioUnidadesMinimo {
    String message() default "{productoPrecioUnidadesMinimo.mensajePorDefecto}";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

Y si en la anotación el cambio es pequeño en la implementación no hay nada que denote que es un validador de clase por que la única diferencia con el primer ejemplo es que ahora el objeto que vamos a validar es de una de nuestras clases pero perfectamente podría ser un atributo de otra clase.


package com.programandoointentandolo.tsb.validator;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

import com.programandoointentandolo.tsb.entity.Producto;

public class ProductoPrecioUnidadesMinimoValidator
        implements ConstraintValidator<ProductoPrecioUnidadesMinimo, Producto> {

    @Override
    public boolean isValid(Producto producto, ConstraintValidatorContext context) {
        if (producto.getPrecio() == null || producto.getUnidadesMinimas() == null) {
            return false;
        }
        
        return producto.getPrecio() * producto.getUnidadesMinimas() > 10;
    }

}

Una cosa que sí que tenemos que tener en cuenta es que aunque dentro de la clase tengamos validadores por ejemplo para que los atributos no sean null en el validador vamos a tener hacer la comprobación porque si no nos va a fallar nuestro código.

Y finalmente como en el caso anterior le añadimos la anotación de nuestro validador personalizado a nuestra clase para que se valide.


package com.programandoointentandolo.tsb.entity;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;

import com.programandoointentandolo.tsb.validator.ProductoCodigo;
import com.programandoointentandolo.tsb.validator.ProductoPrecioUnidadesMinimo;

@ProductoPrecioUnidadesMinimo
@Entity
public class Producto {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    @ProductoCodigo
    @Size(min = 3, max = 8)
    @Pattern(regexp = "[A-Z0-9]+")
    private String codigo;

    @NotEmpty
    private String nombre;

    @NotNull
    @Min(2)
    @Max(100)
    private Double precio;
    
    @NotNull
    @Min(1)
    private Integer unidadesMinimas;

    public Producto() {

    }

    // Getters y Setters

}

Bueno y como en nuestro ejemplo de aplicación de momento seguimos utilizando Thymeleaf si queremos que se muestre el mensaje de error cuando el validador de clase falle nos queda añadirlo. La forma de mostrar este tipo de mensajes de error es la misma que para los mensajes asociados a un atributo, pero en estos casos en lugar de poner el nombre del atributo tendremos que poner global, por lo tanto, para mostrarlo como el resto de mensajes añadiríamos lo siguiente:


<div class="form-group row">
    <label class="col-sm-2 col-form-label">Unidades mínimas</label>
    <div class="col-sm-6">
        <input type="text" th:field="*{unidadesMinimas}">
        <small class="form-text text-danger" th:if="${#fields.hasErrors('unidadesMinimas')}" th:errors="*{unidadesMinimas}"></small>
        <small class="form-text text-danger" th:if="${#fields.hasErrors('global')}" th:errors="global"></small>
    </div>
</div>

Como siempre si puedes encontrar el código completo de los ejemplos en github, si quieres verlo tal y como estaba en el momento correspondiente a este post puedes hacerlo desde este enlace y si prefieres echarle un vistazo a como se encuentra en este momento o al repositorio en general pues puedes hacerlo aquí.