viernes, 8 de junio de 2012

Custom Validation Constraints con anotaciones en Java

Una buena manera de validar formularios es mediante las anotaciones de Hibernate, pero es posible que nuestra aplicación tenga unas necesidades que van más allá de lo que esta librería ofrece. Voy a explicar como definir anotaciones propias para usarlas en nuestros formularios.

En el ejemplo voy a crear una anotación que valide el campo DNI de un formulario.

Lo primero es definir la anotación:

package com.empresa.utils.constraints;

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;

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DniValidator.class)
public @interface Dni {
   String message() default "{errors.dni.invalid}";
   Class<?>[] groups() default {};
   Class<? extends Payload>[] payload() default {};
}

En el campo message indicaremos el mensaje que queremos que aparezca cuando el valor introducido no sea valido. Podemos pasarle el mensaje directamente o la clave de nuestro properties i18n.

Los campos groups y payload tienen que tener el valor por defecto que se muestra.

Por otra parte, el campo @Target indica que elementos Java soportan nuestra anotación (atributos, métodos, clases, etc.), en nuestro caso, solo atributos de clase.

El campo @Retention indica cuando se hace la validación, en nuestro caso, en tiempo de ejecución.

Y el campo @Constraint indica que clase validara la anotación definida. Aquí estamos indicándole que la validación la hará la clase DniValidator, que definiremos a continuación:

package com.empresa.utils.constraints;

import java.util.regex.Pattern;

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

public class DniValidator implements ConstraintValidator<Dni, String> {
 
   private static Pattern dniPattern;
   private static String cadenaLetras;
 
   public void initialize(Dni dni) {
      dniPattern = Pattern.compile("\\d{8}[a-zA-Z]");
      cadenaLetras = "TRWAGMYFPDXBNJZSQVHLCKE";
   }

   public boolean isValid(String value, ConstraintValidatorContext context) {
      if(dniPattern.matcher(value).matches()){
         int num = Integer.parseInt(value.substring(0, value.length()-1));
         String letra = value.substring(value.length()-1);
         int pos = num % 23;
  
         if(cadenaLetras.substring(pos, pos+1).equalsIgnoreCase(letra)){
            return true;
         }
      }
      return false;
   }
}

Lo más importante en este punto es que nuestra clase tiene que implementar la interfaz ConstraintValidator, que tendrá 2 parámetros: el primero será nuestra anotación (la interfaz que hemos definido en el paso anterior), y el segundo el tipo del valor que recibirá el campo que queremos validar (en nuestro caso un String).

La interfaz Constraintvalidator nos obligará a implementar los métodos initialize y isValid. En el primer no es obligatorio hacer nada, puede dejarse vacío.

En el método isValid es donde deberemos hacer las validaciones por las que hemos creado nuestra anotación. En caso de que las cumpla todas, devolveremos true.En el ejemplo se está comprobando que el valor introducido cumple el patrón de un DNI (8 dígitos + 1 letra), y que la letra es la esperada para el número indicado. El algoritmo lo he sacado de internet, quien tenga curiosidad que busque ;) Si encontráis alguna manera de optimizar la comprobación será bien recibida.

Ahora ya solo nos queda empezar a usar nuestra anotación. Para ello, en nuestro bean:

@Entity
@Table(name="empleados")
public class Empleado {
    @Id @GeneratedValue
    @Column (name="id")
    private int id;
    @NotBlank
    @Dni
    @Column (name="dni")
    private String dni;
    @NotBlank
    @Column (name="nombre")
    private String nombre; 
    @DateTimeFormat(pattern="dd/MM/yyyy")
    @NotNull
    @Column (name="fecha_alta")
    private Date fechaAlta;

    ...
}

Con esto, cada vez que hagamos submit de nuestro formulario, a parte de las demás constraints que aparecen, se comprobará que el valor introducido es un DNI válido.

De esta forma, ya estaría todo, pero yo encontré un problema que no me gusto nada: El campo nombre y el campo dni tienen además la constraint @NotBlank, por lo que, si dejamos en blanco alguno de esos campos, la aplicación muestra una alerta. Y como nuestro campo dni tiene la nueva anotación @Dni, y que estando en blanco obviamente nunca va a cumplir las condiciones, muestra otra alerta diciendo que no es un DNI valido.

Personalmente, no me gustaba la idea de ver 2 alertas. En caso de que un campo este en blanco, solo quiero ver la alerta que dice que el campo no puede ser vacío. Existen formas elegantes de solucionar esto, definiendo grupos para cada constraint y haciendo uso de @GroupSequence, pero tampoco me convencía. Esta técnica establece que cada constraint pertenece un grupo, y hasta que no se han validado todas las del grupo A, no se comienza a validar las del grupo B. Con esto, si el campo nombre permanece vacío, y dni contiene un valor invalido, solo muestra la alerta para el campo nombre (suponiendo que las validaciones @NotBlank se ejecutan antes que las @Dni).

Lo que yo quiero es que funcionen todas las validaciones a la vez, y que si el campo esta en blanco, solo me avise de eso. Para conseguir esto hice lo siguiente: a la hora de implementar el método isValid, si el campo viene vacío, lo doy por valido (return true), con lo que no muestra el mensaje de DNI inválido, y sera la anotación @NotBlank la encargada de avisarme de que el campo es invalido y de parar la ejecución de la aplicación.

Por lo tanto, el método isValid queda así:

   public boolean isValid(String value, ConstraintValidatorContext context) {
      if(value.isEmpty()){
         return true;
      }
      if(dniPattern.matcher(value).matches()){
         int num = Integer.parseInt(value.substring(0, value.length()-1));
         String letra = value.substring(value.length()-1);
         int pos = num % 23;
  
         if(cadenaLetras.substring(pos, pos+1).equalsIgnoreCase(letra)){
            return true;
         }
      }
      return false;
   }

Con esto, todo funciona como yo quiero ;)

No hay comentarios:

Publicar un comentario