Validaciones de atributos con Bean Validation\Hibernate Validator

Última actualización: 02/11/2020

logo java

El objetivo de la especificación Bean Validation es establecer un estándar para la definición e implementación de restricciones en clases Java del tipo «el atributo nombre no puede ser nulo», «el atributo cantidad debe ser un número positivo mayor que 10», etc. La aplicación más evidente de estas restricciones es la realización de validaciones sobre los datos de entrada de un sistema, ya sean proporcionados por un usuario a través de un formulario o de forma automática por otro proceso.

Siendo un estándar compatible con Java SE puede ser utilizado en cualquier aplicación e integrado fácilmente con framewoks tan populares como Spring o Struts 2, además de en cualquier servidor de aplicaciones JEE como por ejemplo WildFly. Se puede encontrar un ejemplo básico de integración en Spring Boot en el tutorial Introducción a Spring Boot: Aplicación Web con servicios REST y Spring Data JPA.

Bean Validation es una especificación así que para poder utilizarla tenemos que recurrir a un producto que la implemente. La implementación de referencia, y la única oficialmente certificada para la versión más reciente de la especificación, es Hibernate Validator. Otra alternativa, y que personalmente nunca he utilizado, es Oval.

En este pequeño tutorial veremos cómo empezar a utilizar este estándar en nuestras aplicaciones Java de la mano de Hibernate Validator. Una vez adquiridos estos conocimientos podemos continuar con la Creación de validadores con Bean Validation\Hibernate Validator.

Lo primero que tenemos que hacer es incluir en el classpath de nuestra aplicación las librerías necesarias para utilizar Hibernate Validator. En nuestro proyecto de ejemplo con Maven tendremos el siguiente pom.xml.

<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.danielme.blog.validation</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>1.0</version>
    <name>Hibernate Validator</name>
    <packaging>jar</packaging>

    <description>JSR 380 with Hibernate Validator</description>

    <properties>
        <java.version>1.8</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

        <mavencompiler.version>3.8.1</mavencompiler.version>
        <hibernate.validator.version>6.1.6.Final</hibernate.validator.version>
        <java.el.api.version>3.0.0</java.el.api.version>
        <java.el.impl.version>2.2.6</java.el.impl.version>
        <junit.version>4.13.1</junit.version>
    </properties>

    <dependencies>

        <dependency>
            <groupId>org.hibernate</groupId>
            <artifactId>hibernate-validator</artifactId>
            <version>${hibernate.validator.version}</version>
        </dependency>

        <dependency>
            <groupId>javax.el</groupId>
            <artifactId>javax.el-api</artifactId>
            <version>${java.el.api.version}</version>
        </dependency>

        <dependency>
            <groupId>org.glassfish.web</groupId>
            <artifactId>javax.el</artifactId>
            <version>${java.el.impl.version}</version>
        </dependency>

        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>${junit.version}</version>
            <scope>test</scope>
        </dependency>


    </dependencies>

    <build>

        <plugins>

            <plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>${mavencompiler.version}</version>
                <configuration>
                    <encoding>${project.build.sourceEncoding}</encoding>
                    <source>${jre.version}</source>
                    <target>${jre.version}</target>
                </configuration>
            </plugin>

        </plugins>

    </build>

</project>

La especificación Bean Validation define un conjunto de anotaciones que proporcionan una colección de restricciones genéricas y básicas y que podemos comprobar en la documentación. Estas restricciones que obtenemos de serie se complementan con otras anotaciones añadidas por Hibernate y que por tanto están fuera del estándar.

Vamos a crear un bean denominado User como el siguiente.

Cursos de programación

package com.danielme.blog.validation.model;

public class User {

    private String name;
    private String email;
    private Integer phoneNumber;
    private boolean legalAge;
    private String personalBlog;
    private List<String> tags;

    ...getters and setters

}

Ahora añadimos restricciones de forma individual para cada atributo. El ejemplo no pretende ser exhaustivo sino una demostración básica de cómo utilizar Hibernate Validator aplicado a los atributos de una clase. También se pueden aplicar a parámetros y retornos de métodos, aunque esta funcionalidad queda fuera del alcance del tutorial

  • El nombre es un campo obligatorio con un tamaño comprendido entre 5 y 50 caracteres.
    @NotNull
    @Size(min = 5, max = 50)
    private String name;
    

    En este ejemplo definimos el tamaño del String, pero si simplemente queremos que contenga al menos un carácter podemos utilizar @NotBlank.

  • El email es obligatorio y debe tener el correspondiente formato.
    @NotNull
    @Email
    private String email;
    
  • El número de teléfono es opcional pero si se proporciona debe ser un número de 9 dígitos que empiece por seis.
    @Min(600000000)
    @Max(699999999)
    private Integer phoneNumber;
    

    Como alternativa se puede utilizar la anotación @Range propia de Hibernate.

  • Como alternativa al número de teléfono y ejemplo de expresiones regulares, podemos definir la misma restricción del siguiente modo.
    @Pattern(regexp = "[6][0-9]{8}")
    private String phoneNumberAsString;
    
  • El consentimiento de edad legal debe ser siempre true.
    @AssertTrue
    private boolean legalAge;
    
  • La dirección del blog debe ser una url válida.
    @URL
    private String personalBlog;
    

    Esta anotación es propia de Hibernate.

  • Algunas restricciones pueden ser aplicadas a colecciones. Por ejemplo, vamos a exigir que la lista de tags no esté vacía (aunque sí se permite que pueda null) y tenga entre 3 y 10 elementos.
    @NotEmpty
    @Size(min = 3, max = 10)
    private List<String> tags;
    

El cumplimiento de estas restricciones por parte de cualquier instancia de la clase User se realiza con una clase de tipo Validator que debe ser instanciada con una clase de tipo ValidatorFactory.

ValidatorFactory validationfactory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();

Si usamos Bean Validation de forma integrada con un framework como por ejemplo Spring MVC, la validación de las restricciones se invocará según defina esa integración y tendremos que echar un vistazo a la documentación. En un servidor de aplicaciones JEE podemos inyectar con CDI Beans tanto la factoría como un validador creado con la configuración por defecto.

@Inject
private Validator validator;
@Inject
private ValidatorFactory validatorFactory;

Probemos el funcionamiento de las validaciones con el siguiente test de JUnit 4.

package com.danielme.blog.validation;

import static org.junit.Assert.assertEquals;

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.User;

public class ValidatorTest {

    private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();

    @Test
    public void testEmpty() {
        Set<ConstraintViolation<User>> violations = validator.validate(new User());
        assertEquals(4, violations.size());
        violations.forEach(v -> System.out.println(
                v.getPropertyPath() + " : " + v.getMessageTemplate() + " = " + v.getMessage()));
    }
}

Si lo ejecutamos veremos que no se cumplen las restricciones @NotNull

legalAge : {javax.validation.constraints.AssertTrue.message} = must be true
name : {javax.validation.constraints.NotNull.message} = must not be null
email : {javax.validation.constraints.NotNull.message} = must not be null

Ahora ampliamos el test con algún caso más para ver el funcionamiento de las validaciones.

    @Test
    public void testNotNull() {
        Set<ConstraintViolation<User>> violations = validator.validate(buildValidUser());
        assertTrue(violations.isEmpty());
    }

    @Test
    public void testEmail() {
        User user = buildValidUser();
        user.setEmail("mail.com");
        Set<ConstraintViolation<User>> violations = validator.validate(user);
        assertEquals("user.email.mask", violations.iterator().next().getMessage());
    }

    @Test
    public void testUrlError() {
        User user = buildValidUser();
        user.setPersonalBlog("www.danielme.com");
        Set<ConstraintViolation<User>> violations = validator.validate(user);
        assertEquals("user.personalBlog.url", violations.iterator().next().getMessage());
    }

    @Test
    public void testUrlSucces() {
        User user = buildValidUser();
        user.setPersonalBlog("https://www.danielme.com");
        Set<ConstraintViolation<User>> violations = validator.validate(user);
        assertTrue(violations.isEmpty());
    }
    
    @Test
    public void testEmptyTags() {
        User user = buildValidUser();
        user.setTags(null);
        Set<ConstraintViolation<User>> violations = validator.validate(user);
        assertEquals("user.tags.empty", violations.iterator().next().getMessage());
    }

    private User buildValidUser() {
        User user = new User();
        user.setName("Harris");
        user.setEmail("harris@gmail.com");
        user.setLegalAge(true);
        List<String> tags = new LinkedList<>();
        tags.add("Java");
        tags.add("Spring");
        tags.add("Android");
        user.setTags(tags);
        return user;
    }

Vemos que por defecto las anotaciones proporcionan un mensaje de error genérico y descriptivo, pero generalmente vamos a querer definir nuestros propios mensajes de error personalizados. Esto podemos hacerlo utilizando el atributo message de las anotaciones.

@NotNull(message = "User name cannot not be empty")
@Size(min = 5, max = 50, message = "The length of the name must be between 5 and 50 characters")
private String name;

Definir el mensaje que verá el usuario tal cual me parece una mala práctica ya que habitualmente tendremos que localizar nuestras aplicaciones en varios idiomas. Además, tenemos los mensajes codificados dentro del código fuente lo que obliga a recompilarlo ante cualquier cambio en los mismos. Por ello recomiendo utilizar una clave que permita identificar el mensaje de error a mostrar, y obtener el texto de ese mensaje en el idioma adecuado a partir de ficheros de localización.

Siguiendo esta estrategia voy a definir los mensajes para el bean User utilizando unas claves para los mismos.

public class User {

    @NotNull(message = "user.name.empty")
    @Size(min = 5, max = 50, message = "user.name.size")
    private String name;

    @NotNull(message = "user.email.empty")
    @Email(message = "user.email.mask")
    private String email;

    @Min(value = 600000000, message = "user.phoneNumber.min")
    @Max(value = 699999999, message = "user.phoneNumber.max")
    private Integer phoneNumber;

    @Pattern(regexp = "[6][0-9]{8}", message = "user.phoneNumberAsString.mask")
    private String phoneNumberAsString;

    @AssertTrue(message = "user.legalAge.false")
    private boolean legalAge;

    @URL(message = "user.personalBlog.url")
    private String personalBlog;

    @NotEmpty(message = "user.tags.empty")
    @Size(min = 3, max = 10, message = "user.tags.size")
    private List<String> tags;

La resolución de los textos localizados para cada clave depende de la integración que tenga el framework que estemos utilizando, pero hay un mecanismo estándar:

  • La clave debe estar envuelta entre llaves
    @NotNull(message = "{user.name.empty}")
    private String name;
    
  • En el classpath de la aplicación (/src/main/resources) creamos para cada idioma ficheros .properties. El fichero con los textos por defecto será ValidationMessages.properties, mientras que para cada idioma seguimos la convención de crear un fichero con el nombre «ValidationMessages» seguido del código de localización según la respuesta de la llamada a Locale.getDefault(). Las tildes van en Unicode.
    user.name.empty = name is empty
    
    user.name.empty = El nombre est\u00E1 vac\u00EDo
    

Los siguientes tests comprueban que se están utilizando los mensajes localizados en los properties para la validación con la clave user.name.empty. Los mensajes de los errores se resuelven en función del locale que tenga configurado Java por defecto en el momento de ejecutarse la validación.

    @Test
    public void testDefaultLanguage() {
        Locale.setDefault(Locale.ENGLISH);
        ResourceBundle rb = ResourceBundle.getBundle("ValidationMessages");

        Set<ConstraintViolation<User>> violations = validator.validate(new User());

        assertTrue(violations.stream().anyMatch(v->v.getMessage().equals(rb.getString("user.name.empty"))));
    }

    @Test
    public void testEsLanguage() {
        Locale.setDefault(new Locale("es"));
        ResourceBundle rb = ResourceBundle.getBundle("ValidationMessages_es");

        Set<ConstraintViolation<User>> violations = validator.validate(new User());

        assertTrue(violations.stream().anyMatch(v->v.getMessage().equals(rb.getString("user.name.empty"))));
    }

Validaciones en cascada

Bean Validation permite la aplicación de restricciones en cascada para que se validen las dependencias de otras clases que tenga la clase a validar. Por ejemplo, vamos a crear la siguiente clase.

package com.danielme.blog.validation.model;

import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;

public class Address {

    @NotNull(message = "address.line1.empty")
    @Size(min = 5, max = 50, message = "address.line1.size")
    private String line1;

    private String line2;

    @NotNull(message = "address.zipcode.empty")
    @Size(min = 2, max = 10, message = "address.zipcode.size")
    private String zipCode;

    @NotNull(message = "address.countrycode.empty")
    @Size(min = 2, max = 2, message = "address.countrycode.size")
    private String countryCode;

    ...setters and getters
}

Y la relacionamos con User.

private Address address;

public Address getAddress() {
      return address;
  }
public void setAddress(Address address) {
      this.address = address;
  }

En el siguiente test se crea un User sin errores de validación y se relaciona con un objeto de tipo Address que no verifica las restricciones de sus atributos. Comprobamos que la validación de User no devuelve ninguna violación.

 @Test
 public void testAddress() {
        User user = buildValidUser();
        Address address = new Address();
        user.setAddress(address);
        Set<ConstraintViolation<User>> violations = validator.validate(user);
        assertTrue(violations.isEmpty());
}

Si queremos que al validar un objeto de tipo User también se apliquen las restricciones del objeto Address que pueda contener basta con anotar el atributo con @Valid.

@Valid
private Address address;

Ahora actualizamos el test para comprobar que recibimos los tres errores esperados en Address.

    @Test
    public void testAddress() {
        User user = buildValidUser();
        Address address = new Address();
        user.setAddress(address);
        Set<ConstraintViolation<User>> violations = validator.validate(user);
        assertEquals(3, violations.size());
        violations.forEach(v -> System.out.println(v.getPropertyPath() + " : " + v.getMessage()));
    }
address.line1 : address.line1.empty
address.zipCode : address.zipcode.empty
address.countryCode : address.countrycode.empty

Grupos

Las restricciones de una clase se pueden organizar en grupos de tal modo que podemos elegir qué grupo de restricciones queremos validar cada vez que realizamos una validación de un objeto de la clase. De hecho, todas las restricciones para las que no definamos su agrupación ya están asociadas al grupo Default.

Vamos a crear la siguiente clase que es una copia de la clase Address.

package com.danielme.blog.validation.model;

import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;

public class AddressWithGroup {

    @NotNull(message = "address.line1.empty")
    @Size(min = 5, max = 50, message = "address.line1.size")
    private String line1;

    private String line2;

    @NotNull(message = "address.zipcode.empty")
    @Size(min = 2, max = 10, message = "address.zipcode.size")
    private String zipCode;

    @NotNull(message = "address.countrycode.empty")
    @Size(min = 2, max = 2, message = "address.countrycode.size")
    private String countryCode;
    
...getters y setters
}

Obsérvese que el código de país es obligatorio, pero supongamos que no siempre queremos aplicar esa restricción. Lo que haremos es crear dos grupos de restricciones:

  1. Validaciones por defecto: se validan todas las restricciones.
  2. Grupo «Sin país»: no queremos que se aplique la restricción @NotNull para país. Esto es, tenemos que agrupar todas las demás en un grupo propio.

Para indicar los grupos a los que pertenecen las restricciones usamos el atributo groups. Los grupos toman su nombre de una interfaz vacía. La interfaz para el grupo Default ya la tenemos y se aplica de forma automática a todas las restricciones para las que no definimos explícitamente su grupo, así que creamos una interfaz denominada por ejemplo NoCountry y definimos los grupos.

public class AddressWithGroup {

    public interface NoCountry {
    }

    @NotNull(message = "address.line1.empty", groups = { NoCountry.class, Default.class })
    @Size(min = 5, max = 50, message = "address.line1.size", groups = { NoCountry.class,
            Default.class })
    private String line1;

    private String line2;

    @NotNull(message = "address.zipcode.empty", groups = { NoCountry.class, Default.class })
    @Size(min = 2, max = 10, message = "address.zipcode.size", groups = { NoCountry.class,
            Default.class })
    private String zipCode;

    @NotNull(message = "address.countrycode.empty")
    @Size(min = 2, max = 2, message = "address.countrycode.size", groups = { NoCountry.class,
            Default.class })
    private String countryCode;

Obsérvese que la validación @NotNull de countryCode no está en el grupo NoCountry, pero sí en el Default (como sólo pertenece a este grupo por defecto no hace falta indicarlo). En el siguiente validamos un objeto AddressWithGroup vacío con las validaciones por defecto.

@Test
public void testAddressWithGroupDefault() {
    AddressWithGroup address = new AddressWithGroup();
    Set<ConstraintViolation<AddressWithGroup>> violations = validator.validate(address);
    assertEquals(3, violations.size());
    violations
              .forEach(v -> System.out.println(v.getPropertyPath() + " : " + v.getMessage()));
}
zipCode : address.zipcode.empty
line1 : address.line1.empty
countryCode : address.countrycode.empty

Pero si sólo queremos validar las restricciones del grupo NoCountry, el test equivalente es el siguiente

@Test
public void testAddressWithGroupNoCountry() {
    AddressWithGroup address = new AddressWithGroup();
    Set<ConstraintViolation<AddressWithGroup>> violations = validator.validate(address, NoCountry.class);
    assertEquals(2, violations.size());
    violations
                .forEach(v -> System.out.println(v.getPropertyPath() + " : " + v.getMessage()));
    }
zipCode : address.zipcode.empty
line1 : address.line1.empty

De este modo el @NotNull de countryCode ahora ya no se valida al no formar parte del grupo indicado (NoCountry.class).

Código de ejemplo
El código de ejemplo del tutorial completo se encuentra en GitHub e incluye el código del tutorial Creación de validadores con Bean Validation\Hibernate Validator. Para más información sobre cómo utilizar GitHub consultar este artículo.

Master Pyhton, Java, Scala or Ruby

Deja una respuesta

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Salir /  Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Salir /  Cambiar )

Conectando a %s

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.