Jakarta Bean Validation

A validação de dados é um aspecto crucial em qualquer aplicação, pois nunca devemos confiar inteiramente nas informações fornecidas pelos usuários. Embora seja possível realizar validações utilizando uma série de condicionais if, essa abordagem, apesar de funcional, não é a mais eficiente ou elegante. No ecossistema Java, temos uma API dedicada para simplificar essa tarefa: a Jakarta Bean Validation (também conhecida como JSR 380). Essa especificação permite validar objetos de forma prática e eficaz por meio de anotações intuitivas e poderosas, tornando o processo de validação muito mais simples e organizado.

A Bean Validation, como uma API do Java, possui uma especificação e uma implementação padrão, que é o Hibernate Validator. .

A API opera por meio de anotações, que geralmente são aplicadas diretamente aos atributos dos objetos que desejamos validar. Ela oferece uma variedade de anotações, cada uma projetada para implementar uma regra de validação específica, como verificação de valores nulos, limites numéricos, padrões de texto e muito mais.

Veja uma lista das anotações disponíveis no Bean Validation

AnotaçãoDescrição
@AssertFalseVerifica se o elemento anotado é falso.
@AssertTrueVerifica se o elemento anotado é verdadeiro.
@DecimalMax(value=, inclusive=)Verifica se o elemento anotado é menor que o estipulado em value ou menor ou igual caso inclusive tenha o valor true. O valor padrão de inclusive é false.
@DecimalMin(value=, inclusive=)Semelhante ao anterior, mas verifica se o valor é maior ou maior ou igual ao estipulado em value.
@Digits(integer=, fraction=)Verifica se o valor anotado é um número com até n dígitos inteiros especificados em integer e n dígitos fracionários especificados em fraction.
@EmailVerifica se o email passado é um endereço de e-mail válido.
@FutureVerifica se uma data é no futuro.
@FutureOrPresentVerifica se uma data é no futuro ou no presente.
@Max(value=)Verifica se um elemento anotado é menor ou igual ao valor especificado em value.
@Min(value=)Verifica se um elemento anotado é maior ou igual ao valor especificado em value.
@NotBlankVerifica se uma CharSequence(String) não é nula ou vazia após a remoção de espaços em branco (trim).
@NotEmptyVerifica se o elemento anotado não é nulo ou vazio(não tenha nenhum caractere ou elemento). Pode ser utilizado em List, Map e arrays.
@NotNullVerifica se o elemento anotado não é nulo.
@NegativeVerifica se o elemento anotado é um valor negativo.
@NegativeOrZeroVerifica se o elemento anotado é um valor negativo ou zero.
@PastVerifica se uma data é no passado, anterior a data atual.
@PastOrPresentVerifica se uma data é no passado ou presente, anterior a data atual ou igual.
@Pattern(regex=, flags=)Verifica se a CharSequence(String)corresponde ao regex fornecido. Útil para validações de padrões.
@PositiveVerifica se o elemento anotado é um valor positivo.
@PositiveOrZeroVerifica se o elemento anotado é um valor positivo ou zero.
@Size(min=, max=)Verifica se o tamanho do elemento está entre min e max. Aplica-se a StringCollectionMap e arrays.
Tabela com as anotações disponibilizadas pela API do Jakarta Bean Validation

Além das anotações definidas na API, a implementação do Hibernate Validator também oferece algumas anotações adicionais muito úteis que podem ser utilizadas em seus projetos.

AnotaçãoDescrição
@Range(min=, max=)Verifica se o elemento anotado está entre(inclusive) os valores estipulados de min e max. É aplicado em valores numéricos.
@URL(protocol=, host=, port=, regexp=, flags=)Verifica se a String anotada é uma URL válida de acordo com RFC2396. Se qualquer um dos parâmetros opcionais protocol, host ou port forem fornecidos, os fragmentos de URL correspondentes devem corresponder aos valores especificados.
@UUIDVerifica se a String anotada é um identificador UUID válido de acordo com RFC 4122.
@CNPJVerifica se a String anotada corresponde um número de CNPJ válido
@CPFVerifica se a String anotada corresponde a um número de CPF válido.
Tabela com algumas anotações disponibilizadas exclusivamente pela implementação Hibernate

Todas as anotações possuem, além de seus atributos específicos, o atributo message, que permite definir uma mensagem personalizada para ser exibida em caso de erro de validação.

Além disso, é importante destacar que um mesmo atributo pode ser anotado com várias anotações de validação, permitindo a aplicação de múltiplas regras de validação sobre ele. Isso oferece maior flexibilidade e controle na validação dos dados.

@NotNull(message = "O nome não pode ser vazio")
@Min(value=3, message="O nome deve ter no mínimo 3 caracteres")
private String nome;
    
@Email(message="O email não é válido")
private String email;

Validando objetos anotados

Vamos a um exemplo prático de validação. Imagine que você precise validar um usuário com os seguintes atributos: nomeusernameemailpassword e cpf, com as seguintes regras de validação:

  • nome: não pode estar em branco.
  • username: não pode estar em branco e deve ter entre 3 e 50 caracteres.
  • email: não pode estar em branco e deve ser um endereço de e-mail válido.
  • password: não pode estar em branco e deve ter entre 6 e 50 caracteres.
  • cpf: não pode estar em branco e deve ser um número de CPF válido.

Veja como ficaria a classe anotada:

import org.hibernate.validator.constraints.br.CPF;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;

public class Usuario {
    
    @NotBlank(message = "O nome não pode ser vazio")
    private String nome;
    
    @NotBlank(message = "O username não pode ser vazio")
    @Size(min = 3, max=50, message = "O username deve ter entre 3 e 50 caracteres")
    private String username;

    @NotEmpty(message = "O e-mail não pode ser vazio")
    @Email(message="O email não é válido")
    private String email;

    @NotBlank(message = "A senha não pode ser vazia")
    @Size(min = 6, max=50, message = "A senha deve ter entre 6 e 50 caracteres")
    private String password;

    @NotBlank(message = "O CPF não pode ser vazio")
    @CPF(message = "O CPF não é válido")
    private String cpf;

  /* SETs e GETs */
    
}

Agora, vamos executar a validação de um objeto dessa classe. Para isso, precisamos adicionar as dependências do Bean Validation ao projeto. Utilizaremos o Maven para gerenciar essas dependências.

 <dependency>
    <groupId>org.hibernate.validator</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>8.0.2.Final</version>
</dependency>
<dependency>
    <groupId>org.glassfish.expressly</groupId>
    <artifactId>expressly</artifactId>
    <version>5.0.0</version>
</dependency>

Após instalar as dependências vamos ao código de validação

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

Usuario usuario = new Usuario();
usuario.setNome("Rodrigo");
usuario.setUsername("ro"); // muito curto
usuario.setEmail("rodrigo-gmail.com"); // email inválido
usuario.setPassword(""); // senha vazia invalida
usuario.setCpf("123.654.789-00"); // cpf inválido

Set<ConstraintViolation<Usuario>> constraintViolations = validator.validate(usuario);

System.out.println(constraintViolations.size());
constraintViolations.forEach(cv -> {
    System.out.println(cv.getPropertyPath() + " - " + cv.getMessage());
});

// Saída:
// 5
// password - A senha deve ter entre 6 e 50 caracteres
// email - O email não é válido
// cpf - O CPF não é válido
// password - A senha não pode ser vazia
// username - O username deve ter entre 3 e 50 caracteres

O primeiro passo para realizar a validação é obter uma instância do objeto Validator. Para isso, utilizamos o ValidatorFactory, que pode ser criado chamando o método estático buildDefaultValidatorFactory() da classe Validation. Esse processo é ilustrado nas linhas 1 e 2 do exemplo abaixo.

Das linhas 4 a 9, criamos o objeto e atribuímos valores aos seus atributos, incluindo alguns valores inválidos propositalmente para testar a validação.

Na linha 11, utilizamos o método validate do objeto Validator, passando o objeto que desejamos validar. Esse método retorna um Set<ConstraintViolation>. Se essa coleção estiver vazia, significa que a validação foi bem-sucedida. Caso contrário, cada objeto ConstraintViolation dentro do Set representa um erro de validação específico.

Na linha 13, chamamos o método size do Set para saber quantos erros de validação ocorreram. Na linha 14, utilizamos um foreach para percorrer cada objeto ConstraintViolation resultante da validação. Para cada um deles, chamamos o método getPropertyPath() para obter o campo que falhou na validação e o método getMessage() para recuperar a mensagem de erro associada.

Alguns outros métodos úteis da classe ConstraintViolation incluem:

  • getRootBean(): retorna o objeto que está sendo validado.
  • getInvalidValue(): retorna o valor específico que falhou na validação..

Utilizando Java Bean Validation com o Spring

Como vimos, utilizar o Java Validation de forma independente é bastante simples. No entanto, o uso mais comum é em conjunto com um framework como o Spring Boot. Para demonstrar essa integração, criaremos uma pequena aplicação com um formulário de usuário validado. Para isso, configuraremos um projeto Spring Boot com as seguintes dependências:

<dependencies>
    <dependency>
	    <groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
	<dependency>
	    <groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-validation</artifactId>
	</dependency>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-web</artifactId>
	</dependency>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-test</artifactId>
		<scope>test</scope>
	</dependency>
</dependencies>

Em seguida, criamos um método no Controller responsável por exibir o formulário para o usuário.

@GetMapping("/")
public String index(Model model){
    model.addAttribute("usuario", new Usuario());
    return "index";
}

No formulário, incluímos os campos necessários usando Thymeleaf como template engine.

<form method="POST" th:action="@{/salvar}" th:object="${usuario}">
        <div>
            <label for="nome">Nome:</label>
            <input type="text" id="nome" name="nome">
            <div th:if="${#fields.hasErrors('nome')}" class="error" th:errors="*{nome}" />
        </div>
        <div>
            <label for="username">Username:</label>
            <input type="text" id="username" name="username">
            <div th:if="${#fields.hasErrors('username')}" class="error" th:errors="*{username}" />
        </div>
        <div>
            <label for="email">E-mail:</label>
            <input type="text" id="email" name="email">
            <div th:if="${#fields.hasErrors('email')}" class="error" th:errors="*{email}" />
        </div>
        <div>
            <label for="password">Password:</label>
            <input type="text" id="password" name="password">
            <div th:if="${#fields.hasErrors('password')}" class="error" th:errors="*{password}" />
        </div>
        <div>
            <label for="cpf">CPF:</label>
            <input type="text" id="cpf" name="cpf">
            <div th:if="${#fields.hasErrors('cpf')}" class="error" th:errors="*{cpf}" />
        </div>

        <button type="submit">Salvar</button>

    </form>

O formulário é simples, utilizando apenas alguns atributos do Thymeleaf, com destaque para o th:object, que estabelece a ligação entre os campos do formulário e o bean. O Thymeleaf também disponibiliza elementos para exibir mensagens de erro, como o objeto #fields, o atributo th:errors e o th:errorclass.

O método #fields.hasErrors(...) verifica se um determinado campo possui erros de validação, retornando true em caso positivo e false caso contrário. No exemplo, utilizamos o atributo th:if para exibir a div da mensagem de erro apenas quando o campo apresentar um erro.

Também é possível usar o objeto #fields para recuperar todos os erros de um campo e iterar sobre eles por meio do método #fields.errors(...).

<ul>
    <li th:each="err : ${#fields.errors('username')}" th:text="${err}" />
</ul>

Ou ao invés de iterar sobre os erros podemos utilizar o atributo th:errors, um atributo expecializado que irá exibir todos os erros do campo separados por <br>. Utilizamos esta abordagem no exemplo do formulário.

O atributo especializado th:errorclass verifica se o campo possui erros e, caso positivo, aplica a classe definida. Isso é especialmente útil para destacar visualmente os elementos de input que não passaram na validação.

<input type="text" id="username" name="username" th:errorclass="errorField">

Após criar o formulário, definimos o método no controller responsável por sua validação.

 @PostMapping("/salvar")
 public String addUser(@Valid Usuario usuario, BindingResult result, Model model) {
    if (result.hasErrors()) {
        return "index";
    }
    model.addAttribute("usuario", usuario);
    return "sucesso";
}

Para validar o objeto recebido do formulário, devemos anotá-lo com a anotação @Valid. O resultado da validação será armazenado no objeto BindingResult, que oferece o método hasErrors(), retornando true se houver erros de validação e false caso contrário. Quando ocorrem erros, geralmente redirecionamos o usuário de volta ao formulário original, onde os objetos e atributos especializados do Thymeleaf poderão exibir as mensagens de erro correspondentes.

Além do método hasErrors(), o BindingResult oferece o método rejectValue(), que permite adicionar um erro de validação a um campo específico. Ele recebe o nome do campo inválido, um código e a mensagem de erro. Esse recurso é útil quando precisamos realizar uma validação adicional no controller e queremos repassar o erro para o formulário.

result.rejectValue("nome", "nome.invalid", "nome está errado");

O trecho de codigo acima adiciona um erro de validação para o campo nome com a mensagem “nome está errado”. Ao utilizá-lo lembre-se adicionar antes do teste de bindResult.hasErrors() e do redirecionamento.

Validando API

No exemplo anterior, utilizamos um formulário HTML com Thymeleaf, mas o Bean Validation também pode ser aplicado em uma API REST. Para isso, criamos um método no controller responsável por salvar a entidade e anotamos o objeto recebido no @RequestBody com @Valid, assim como no controller anterior.

Além disso, neste controller implementamos um método anotado com @ExceptionHandler para capturar exceções de validação. Esse método recebe um objeto MethodArgumentNotValidException, do qual podemos obter o BindingResult por meio do método getBindingResult(). A partir dele, acessamos todos os erros utilizando getAllErrors().

Percorrendo essa lista de erros, extraímos o nome do campo que falhou na validação com ((FieldError) error).getField() e a mensagem correspondente com error.getDefaultMessage(). Esses dados são armazenados em um HashMap, que é então retornado para gerar uma resposta JSON.

@PostMapping("/api/salvar")
public ResponseEntity<?> adicionarUsuario(@Valid @RequestBody Usuario usuario) {
    return ResponseEntity.ok(usuario);
}

@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MethodArgumentNotValidException.class)
public Map<String, String> handleValidationExceptions(MethodArgumentNotValidException ex){

    Map<String, String> errors = new HashMap<String,String>();
    ex.getBindingResult().getAllErrors().forEach( (error) -> {
        String fieldName = ((FieldError) error).getField();
        String errorMessage = error.getDefaultMessage();
        errors.put(fieldName, errorMessage);
    });
    return errors;
}

Agora, enviaremos uma requisição POST para o endpoint /api/salvar utilizando o cURL.

curl -X POST -d '{ "nome": "", "username": "", "email": "", "password": "", "cpf": "" }' -H "Content-type: application/json" http://localhost:8080/api/salvar

Como resultado, recebemos a seguinte resposta:

{
    "password":"A senha deve ter entre 6 e 50 caracteres",
    "cpf":"O CPF não é válido",
    "nome":"O nome não pode ser vazio",
    "email":"O e-mail não pode ser vazio",
    "username":"O username não pode ser vazio"
}

Criando Validadores Personalizados

Embora as anotações do Java Bean Validation sejam bastante poderosas, muitas vezes precisamos definir nossas próprias regras de validação. Um exemplo comum é garantir que um e-mail seja único, o que exige acesso ao banco de dados — algo que as anotações padrão não oferecem. A solução para isso é criar um validador personalizado. Vamos começar definindo a anotação:

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

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EmailExistsValidator.class)
public @interface EmailExists {
    String message() default "O e-mail já existe.";

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

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

Na linha 9, especificamos que essa anotação pode ser aplicada apenas a campos de uma classe utilizando @Target(ElementType.FIELD).

Na linha 10, definimos que a anotação estará disponível em tempo de execução, permitindo que o mecanismo de validação do Jakarta Validation API a processe, por meio de @Retention(RetentionPolicy.RUNTIME).

Na linha 11, associamos a anotação à classe EmailExistsValidator, que será responsável por implementar a lógica de validação, utilizando @Constraint(validatedBy = EmailExistsValidator.class).

Por fim, na linha 13, definimos a mensagem de erro padrão que será exibida caso a validação falhe.

Agora, vamos criar a classe responsável pela lógica de validação.

import org.springframework.beans.factory.annotation.Autowired;

import br.dev.botecodigital.validation_spring.models.Usuario;
import br.dev.botecodigital.validation_spring.repositories.UsuarioRepository;
import jakarta.validation.ConstraintValidator;

public class EmailExistsValidator implements ConstraintValidator<EmailExists, String> {

    @Autowired
    private UsuarioRepository usuarioRepository;
    @Override
    public boolean isValid(String value, jakarta.validation.ConstraintValidatorContext context) {
        Optional<Usuario> usuario = this.usuarioRepository.findByEmail(value);
        return usuario.isEmpty();
    }
}

Essa classe é simples e objetiva: ela implementa a interface ConstraintValidator e sobrescreve o método isValid, que recebe o valor a ser validado como parâmetro. Esse método retorna true se a validação for bem-sucedida e false caso contrário.

No nosso caso, o método isValid acessa o repositório para verificar se o e-mail informado já existe no banco de dados. O repositório retorna um objeto Optional, no qual chamamos o método isEmpty(). Se isEmpty() retornar true, significa que nenhum usuário foi encontrado com esse e-mail, tornando-o válido. Se retornar false, significa que o e-mail já está cadastrado, falhando na validação.

Agora, basta anotar o atributo no qual se deseja aplicar a validação.

// ...
@NotEmpty(message = "O e-mail não pode ser vazio")
@Email(message="O email não é válido")
@EmailExists //nossa validação
private String email;
// ...

Recebendo parâmetros nas anotações criadas

Em alguns casos, a anotação de validação precisará receber um valor de parâmetro para ser utilizada corretamente. No exemplo do usuário, por exemplo, poderíamos criar uma anotação para verificar se o valor de um campo é igual ao valor de outro campo, como no caso de validar se a senha e a confirmação da senha são iguais, ou para confirmar se os e-mails coincidem. Vamos ver esse exemplo.

package br.dev.botecodigital.validation_spring.validation;

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

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = CamposIguaisValidator.class)
public @interface CamposIguais{
    String message() default "O campos {campo1} e {campo2} devem ser iguais ";

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

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

    String campo1();
    String campo2();
}

Como precisaremos acessar dois valores do objeto a ser validado, devemos definir que a anotação será aplicada à classe, utilizando @Target({ElementType.TYPE}). Além disso, adicionamos dois atributos, campo1 e campo2 (nas linhas 21 e 22), para especificar na anotação os nomes dos campos a serem comparados.

import java.lang.reflect.Field;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class CamposIguaisValidator implements ConstraintValidator<CamposIguais, Object>{

    private String campo1;
    private String campo2;
    private String message;

    @Override
    public void initialize(CamposIguais constraintAnnotation) {
        this.campo1 = constraintAnnotation.campo1();
        this.campo2 = constraintAnnotation.campo2();
        this.message = constraintAnnotation.message();
    }

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
            
        try {
            Field campo1Field = value.getClass().getDeclaredField(campo1);
            Field campo2Field = value.getClass().getDeclaredField(campo2);
            
            campo1Field.setAccessible(true);
            campo2Field.setAccessible(true);

            Object campo1Value = campo1Field.get(value);
            Object campo2Value = campo2Field.get(value);

            if (campo1Value != null && campo1Value.equals(campo2Value)) {
                return true;
            }

            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate(message)
                    .addPropertyNode(campo2) 
                    .addConstraintViolation();
            
            return false;
        } catch (NoSuchFieldException | SecurityException | IllegalArgumentException | IllegalAccessException e) {
            e.printStackTrace();
            return false;
        }     
    }  
}

Na classe de lógica de validação, CamposIguaisValidator, definimos os atributos campo1 e campo2. No método sobrescrito initialize, que recebe a anotação definida anteriormente, obtemos os valores dos atributos definidos quando a anotação é aplicada à classe e os atribuirmos aos respectivos atributos da classe de validação para posteriormente utilizarmos para pegar os valores do objeto a ser validado.

No método isValid, o objeto a ser validado é passado como argumento(value), uma vez que a anotação é aplicada à classe do objeto. Usando reflection, nas linhas 23 e 24 obtemos os objetos Field representando os atributos do objeto a ser validado correspondentes aos nomes passados nos atributos campo1 e campo2. Nas linhas 26 e 27, alteramos a visibilidade desses campos para caso sejam private, e nas linhas 29 e 30, recuperamos os valores armazenado nos dois dos campos do objeto value. Na linha 32, verificamos se os valores dos dois campos são iguais; se forem, retornamos true (o valor é válido). Caso contrário, desativamos a violação de validação padrão na linha 36 e, na linha 37, criamos uma nova violação de validação para o campo2, adicionando uma mensagem (buildConstraintViolationWithTemplate(message)) e o nome do campo (addPropertyNode(campo2)). Por fim, retornamos false.

Agora, podemos aplicar nossa nova regra de validação em uma classe.

@CamposIguais(campo1="email", campo2="emailConfirmacao")
public class UsuarioDTO {

    /* Outros atributos */

    private String email;
    private String emailConfirmacao;

    /* gets e sets */
}

Bom, esta foi uma pequena introdução à API de validação Bean Validation. Ela é bastante poderosa e torna a tarefa, por vezes tediosa, de validação de dados um pouco mais simples, além de permitir a criação de nossas próprias regras de validação de maneira relativamente fácil. Como sempre, para mais detalhes e para se aprofundar no assunto, consulte a documentação oficial da API. T++!