Cuando recurrimos a la especificación Bean Validation para definir restricciones sobre los atributos de clases Java, en algún momento nos encontraremos con la necesidad de validar restricciones que no sean proporcionadas ni por Bean Validation ni por su implementación «estrella» Hibernate Validator y que no pueden declararse con expresiones regulares (anotación @Pattern). Por ejemplo, realizar alguna comprobación en una base de datos.
Por fortuna, Bean Validation está diseñada para ser totalmente extensible, de tal modo que la creación de validadores personalizados resulte sencilla. Lo veremos en este tutorial.
El primer validador
Empecemos creando una restricción lo más simple posible para familiarizarnos con el proceso de creación de restricciones o validadores personalizados. Este proceso consiste en dos pasos:
- Crear la anotación. Veamos el siguiente ejemplo:
package com.danielme.blog.validation.custom; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.Target; import javax.validation.Constraint; import javax.validation.Payload; import com.danielme.blog.validation.custom.impl.DummyValidator; @Target({ ElementType.FIELD }) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = DummyValidator.class) public @interface Dummy { String message() default "{com.danielme.blog.validation.custom.DummyValidator.message}"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
He definido una anotación aplicable a atributos (@Target) e indicado con @Constraint la clase que implementa la lógica de este validador y que veremos a continuación. Asimismo, la anotación tiene los tres atributos que se esperan de los validadores con valores por defecto incluyendo la clave del mensaje de error.
- Implementar el validador. En concreto, hay que implementar la interfaz ConstraintValidator para la anotación de la validación y la clase del elemento que se valida. En el método isValid recibimos el elemento a validar y tenemos que implementar la validación propiamente dicha.
package com.danielme.blog.validation.custom.impl; import javax.validation.ConstraintValidator; import javax.validation.ConstraintValidatorContext; import com.danielme.blog.validation.custom.Dummy; public class DummyValidator implements ConstraintValidator<Dummy, String> { @Override public boolean isValid(String value, ConstraintValidatorContext context) { return "dummy".equals(value); } }
Y con esto ya tenemos nuestra validación lista para ser aplicada.
package com.danielme.blog.validation.model; import com.danielme.blog.validation.custom.Dummy; import com.danielme.blog.validation.custom.SameValue; import com.danielme.blog.validation.custom.StringRange; public class CustomValidators { @Dummy private String dummy; public String getDummy() { return dummy; } public void setDummy(String dummy) { this.dummy = dummy; } }
Los siguientes tests de JUnit4 nos permiten comprobar el correcto funcionamiento de nuestro código.
package com.danielme.blog.validation; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import java.util.Set; import javax.validation.ConstraintViolation; import javax.validation.Validation; import javax.validation.Validator; import org.junit.Test; import com.danielme.blog.validation.model.CustomValidators; public class CustomValidatorsTest { private Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); @Test public void testDummyNoOk() { CustomValidators customValidators = new CustomValidators(); Set<ConstraintViolation<CustomValidators>> violations = validator .validate(customValidators); assertEquals(1, violations.size()); assertEquals("{com.danielme.blog.validation.custom.DummyValidator.message}", violations.iterator().next().getMessage()); } @Test public void testDummyOk() { CustomValidators customValidators = new CustomValidators(); customValidators.setDummy("dummy"); Set<ConstraintViolation<CustomValidators>> violations = validator .validate(customValidators); assertTrue(violations.isEmpty()); } }
Validadores parametrizables
Es posible añadir parámetros a aquellos validadores en los que los necesitemos simplemente definiéndolos en la anotación. Vamos a crear un validador llamado StringRange que compruebe que el contenido de una cadena esté dentro de un conjunto de valores personalizable para que el validador sea lo más genérico y reutilizable posible.
Al definir la anotación, añadimos un array para contener los valores. No le damos valor por defecto para que sea obligatorio proporcionar esos valores en la anotación.
package com.danielme.blog.validation.custom; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.Target; import javax.validation.Constraint; import javax.validation.Payload; import com.danielme.blog.validation.custom.impl.StringRangeValidator; @Target({ ElementType.FIELD }) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = StringRangeValidator.class) public @interface StringRange { String[] values(); String message() default "{com.danielme.blog.validation.custom.StringRange.message}"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
Para acceder a los atributos de la anotación se sobrescribe el método initialize.
package com.danielme.blog.validation.custom.impl; import java.util.Arrays; import javax.validation.ConstraintValidator; import javax.validation.ConstraintValidatorContext; import com.danielme.blog.validation.custom.StringRange; public class StringRangeValidator implements ConstraintValidator<StringRange, String> { private String[] values; @Override public void initialize(StringRange constraintAnnotation) { values = constraintAnnotation.values(); } @Override public boolean isValid(String value, ConstraintValidatorContext context) { return value == null || Arrays.stream(values).anyMatch(value::equals); } }
Ahora usamos la anotación del siguiente modo:
@StringRange(values = { "Spain", "Mexico" }) private String country;
y probamos con los tests correspondientes.
@Test public void testRangeOk() { CustomValidators customValidators = new CustomValidators(); customValidators.setDummy("dummy"); customValidators.setCountry("Spain"); Set<ConstraintViolation<CustomValidators>> violations = validator .validate(customValidators); assertTrue(violations.isEmpty()); } @Test public void testRangeNoOk() { CustomValidators customValidators = new CustomValidators(); customValidators.setCountry("Germany"); customValidators.setDummy("dummy"); Set<ConstraintViolation<CustomValidators>> violations = validator .validate(customValidators); assertEquals(1, violations.size()); assertEquals("{com.danielme.blog.validation.custom.StringRange.message}", violations.iterator().next().getMessage()); }
Validadores sobre múltiples campos
Un escenario que podemos encontrar es tener que definir una validación que afecte a más de un atributo de la clase. El ejemplo más típico es el de un formulario de registro en el que por seguridad el usuario tiene que proporcionar por ejemplo dos veces su contraseña o número de teléfono.
La creación de la validación anterior es análoga a los ejemplos que hemos visto previamente pero definiendo ahora la anotación a nivel de clase y no de atributo. Para hacer el validador genérico, su implementación se hará para validar objetos de la clase Object, es decir, de cualquier tipo, y en la anotación indicaremos los nombres de los atributos que deben ser iguales. De este modo, la anotación quedaría así:
package com.danielme.blog.validation.custom; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.Target; import javax.validation.Constraint; import javax.validation.Payload; import com.danielme.blog.validation.custom.impl.SameValueValidator; @Target({ ElementType.TYPE }) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = SameValueValidator.class) public @interface SameValue { String field1(); String field2(); String message() default "{com.danielme.blog.validation.custom.SameValue.message}"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
La implementación tiene la particularidad de que debemos acceder a los atributos de la clase mediante reflection. Asimismo la he codificado de tal modo que sólo se compruebe la igualdad de los campos si ambos no son nulos porque la nulidad de los mismos se puede validar con la anotación @NonNull.
package com.danielme.blog.validation.custom.impl; import java.lang.reflect.Field; import javax.validation.ConstraintValidator; import javax.validation.ConstraintValidatorContext; import com.danielme.blog.validation.custom.SameValue; public class SameValueValidator implements ConstraintValidator<SameValue, Object> { private String field1; private String field2; @Override public void initialize(SameValue constraintAnnotation) { field1 = constraintAnnotation.field1(); field2 = constraintAnnotation.field2(); } @Override public boolean isValid(Object object, ConstraintValidatorContext context) { try { Object value1 = getValue(object, field1); Object value2 = getValue(object, field2); return value1 == null || value2 == null || value1.equals(value2); } catch (Exception ex) { throw new IllegalArgumentException( "Cannot read '" + field1 + "', '" + field2 + "' fields from Object " + object, ex); } } private Object getValue(Object object, String fieldName) throws NoSuchFieldException, SecurityException, IllegalArgumentException, IllegalAccessException { Field field = object.getClass().getDeclaredField(fieldName); field.setAccessible(true); return field.get(object); } }
Ahora aplicamos la validación a la clase para exigir que sus atributos de nombre field1 y field2 sean iguales según su implementación de equals().
@SameValue(field1 = "field1", field2 = "field2") public class CustomValidators { private String field1; private String field2;
Los tests correspondientes.
@Test public void testSameValueNoOk() { CustomValidators customValidators = new CustomValidators(); customValidators.setDummy("dummy"); customValidators.setField1("value1"); customValidators.setField2("value2"); Set<ConstraintViolation<CustomValidators>> violations = validator .validate(customValidators); assertEquals(1, violations.size()); assertEquals("{com.danielme.blog.validation.custom.SameValue.message}", violations.iterator().next().getMessage()); } @Test public void testSameValueOk() { CustomValidators customValidators = new CustomValidators(); customValidators.setDummy("dummy"); customValidators.setField1("value"); customValidators.setField2("value"); Set<ConstraintViolation<CustomValidators>> violations = validator .validate(customValidators); assertTrue(violations.isEmpty()); }
Una solución alternativa para evitar el uso de reflection es utilizar una interfaz que defina los métodos que devuelven los valores de los dos campos a validar.
package com.danielme.blog.validation.custom; public interface SameValueFields { Object getField1(); Object getField2(); }
Ahora la anotación no recibe los nombres de los campos.
package com.danielme.blog.validation.custom; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.Target; import javax.validation.Constraint; import javax.validation.Payload; import com.danielme.blog.validation.custom.impl.SameValueViaInterfaceValidator; @Target({ ElementType.TYPE }) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = SameValueViaInterfaceValidator.class) public @interface SameValueViaInterface { String message() default "{com.danielme.blog.validation.custom.SameValue.message}"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
La implementación se crea para validar un objeto que implemente la interfaz lo que evita utilizar reflection.
package com.danielme.blog.validation.custom.impl; import javax.validation.ConstraintValidator; import javax.validation.ConstraintValidatorContext; import com.danielme.blog.validation.custom.SameValueFields; import com.danielme.blog.validation.custom.SameValueViaInterface; public class SameValueViaInterfaceValidator implements ConstraintValidator<SameValueViaInterface, SameValueFields> { @Override public boolean isValid(SameValueFields object, ConstraintValidatorContext context) { return object.getField1() == null || object.getField2() == null || object.getField1().equals(object.getField2()); } }
Por último, para aplicar el validador, además de añadir la anotación a la clase, también tendremos que implementar la interfaz.
@SameValueViaInterface public class CustomValidators implements SameValueFields {
Paso de parámetros externos
Una funcionalidad muy interesante de Hibernate Validator y que está fuera del estándar consiste en el envío de parámetros (payload) a los validadores en el momento de construir el objeto validador. Esto permite parametrizar el comportamiento del validador en tiempo de ejecución lo que nos permite crear validadores dinámicos y flexibles. En la documentación oficial encontramos el siguiente ejemplo, obsérvese que el payload puede ser el idioma que el usuario de nuestra aplicación tenga seleccionado en ese momento:
ValidatorFactory validatorFactory = Validation.byProvider( HibernateValidator.class ) .configure() .constraintValidatorPayload( "US" ) .buildValidatorFactory(); Validator validator = validatorFactory.getValidator();
También se puede definir el payload en cada validador de forma específica en lugar de en la factoría para todos los validadores:
HibernateValidatorFactory hibernateValidatorFactory = Validation.byDefaultProvider() .configure() .buildValidatorFactory() .unwrap( HibernateValidatorFactory.class ); Validator validator = hibernateValidatorFactory.usingContext() .constraintValidatorPayload( "US" ) .getValidator();
El payload lo podemos obtener dentro de nuestro validador con el siguiente código.
@Override public boolean isValid(String value, ConstraintValidatorContext context) { String countryCode = context .unwrap( HibernateConstraintValidatorContext.class ) .getConstraintValidatorPayload( String.class ); ...resto del código }
Código de ejemplo
El código de ejemplo del tutorial completo se encuentra en GitHub (incluye también el código del tutorial Validaciones con Hibernate Validator). Para más información sobre cómo utilizar GitHub consultar este artículo.