Programação para Web II

Fagno Alves Fonseca <fagno.fonseca@ifto.edu.br> Mestre em Modelagem Computacional de Sistemas – UFT.

1. Validação com Bean Validation

Bean Validation é a especificação que permite declarar restrições sobre objetos Java por meio de anotações. O Hibernate Validator implementa essa especificação; o Spring integra a validação ao recebimento de dados, e o Thymeleaf apresenta os erros no formulário.

Neste capítulo, vamos validar o cadastro de uma pessoa antes de salvá-la. O fluxo será: receber os campos, verificar as restrições, reapresentar o formulário quando houver erros e salvar quando os dados forem válidos.

1.1. Dependência e compatibilidade

Adicione o starter de validação ao projeto Spring Boot. A versão da dependência deve ser gerenciada pelo parent ou BOM do Spring Boot utilizado na aplicação.

pom.xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Os exemplos usam Spring Boot 4, Java 17 ou superior e Jakarta Validation 3.1. Utilize jakarta.validation para validação e jakarta.persistence para persistência. A anotação @Transactional usada aqui pertence ao Spring: org.springframework.transaction.annotation.Transactional.

1.2. Principais anotações

As restrições abaixo pertencem ao pacote jakarta.validation.constraints, da especificação Jakarta Validation 3.1.

Anotação Regra Exemplo de uso

@NotNull

Rejeita null.

@NotNull Integer idade;

@NotEmpty

Rejeita null e tamanho zero em texto, coleção, mapa ou array.

@NotEmpty List<String> telefones;

@NotBlank

Exige texto com algum caractere que não seja espaço em branco.

@NotBlank String nome;

@Size

Limita o tamanho de texto, coleção, mapa ou array.

@Size(min = 2, max = 100) String nome;

@Min / @Max

Definem limites numéricos inclusivos.

@Min(18) @Max(120) Integer idade;

@DecimalMin / @DecimalMax

Definem limites decimais; inclusive = false exclui o limite.

@DecimalMin("0.01") BigDecimal preco;

@Positive / @PositiveOrZero

Exigem valor positivo / não negativo.

@Positive Integer quantidade;

@Negative / @NegativeOrZero

Exigem valor negativo / não positivo.

@Negative BigDecimal saldoDevedor;

@Digits

Limita a quantidade de dígitos inteiros e fracionários.

@Digits(integer = 8, fraction = 2) BigDecimal valor;

@Email

Verifica o formato de e-mail.

@Email String email;

@Pattern

Exige correspondência com uma expressão regular.

@Pattern(regexp = "[0-9]{8}") String cep;

@Past / @PastOrPresent

Exigem data passada / passada ou presente.

@PastOrPresent LocalDate nascimento;

@Future / @FutureOrPresent

Exigem data futura / futura ou presente.

@Future LocalDate vencimento;

@AssertTrue / @AssertFalse

Exigem valor booleano verdadeiro / falso.

@AssertTrue Boolean aceitouTermos;

@Null

Exige null.

@Null Long id;

Consulte os tipos aceitos por cada restrição na especificação. Por exemplo, @NotBlank não se aplica a números, e @Size não limita o valor de uma idade.

1.2.1. Obrigatoriedade e valores nulos

@NotNull aceita "" e " "; @NotEmpty rejeita "", mas aceita " "; @NotBlank rejeita ambos e também null.

As demais restrições da tabela, exceto @Null, aceitam null. Combine-as com uma restrição de obrigatoriedade quando necessário. Por exemplo, use @NotBlank @Email para um e-mail obrigatório. A validação de formato não confirma a existência da caixa postal.

Para valores monetários, prefira BigDecimal. @Min e @Max não oferecem suporte portátil a double e float.

1.3. Validando a entidade Pessoa

O cadastro exige nome preenchido, com até 100 caracteres, e idade informada, a partir de 18 anos.

Entidade Pessoa
import java.io.Serializable;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

@Entity
public class Pessoa implements Serializable {

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

    @NotBlank(message = "Informe o nome.")
    @Size(max = 100, message = "O nome deve ter no máximo {max} caracteres.")
    private String nome;

    @NotNull(message = "Informe a idade.")
    @Min(value = 18, message = "A idade deve ser maior ou igual a {value}.")
    private Integer idade;

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getNome() { return nome; }
    public void setNome(String nome) { this.nome = nome; }

    public Integer getIdade() { return idade; }
    public void setIdade(Integer idade) { this.idade = idade; }
}

Usamos Integer para representar a ausência de idade por null. Assim, @NotNull trata o campo não informado e @Min trata o limite. Anotações JPA, como @Entity e @Id, têm outro papel: mapear a persistência.

1.4. Acionando a validação no Controller

@Valid solicita a validação do objeto recebido. A associação dos campos da requisição ao objeto é feita pelo Spring MVC; no exemplo, @ModelAttribute("pessoa") explicita o nome usado no formulário.

O parâmetro BindingResult deve ficar imediatamente após o objeto validado. Ele reúne tanto violações das restrições quanto erros de conversão, como receber abc para uma idade inteira.

Controller
import org.springframework.transaction.annotation.Transactional;
import jakarta.validation.Valid;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

@Transactional
@Controller
@RequestMapping("/pessoas")
public class PessoasController {

    private final PessoaRepository repository;

    public PessoasController(PessoaRepository repository) {
        this.repository = repository;
    }

    @GetMapping("/form")
    public ModelAndView form(@ModelAttribute("pessoa") Pessoa pessoa) {
        return new ModelAndView("pessoas/form");
    }

    @GetMapping("/list")
    public ModelAndView listar(ModelMap model) {
        model.addAttribute("pessoas", repository.findAll());
        return new ModelAndView("pessoas/list", model);
    }

    @PostMapping("/save")
    public ModelAndView save(@Valid @ModelAttribute("pessoa") Pessoa pessoa,
                             BindingResult result,
                             RedirectAttributes attributes) {
        if (result.hasErrors()) {
            return new ModelAndView("pessoas/form");
        }

        repository.save(pessoa);
        attributes.addFlashAttribute("mensagem", "Pessoa cadastrada com sucesso.");
        return new ModelAndView("redirect:/pessoas/list");
    }
}

O exemplo utiliza a interface PessoaRepository extends JpaRepository<Pessoa, Long> apresentada no tópico de Spring Data JPA do capítulo de persistência, com os métodos save() e findAll(), e os templates pessoas/form.html e pessoas/list.html.

O repositório é recebido pelo construtor e armazenado em um campo final. Como a classe possui um único construtor, o Spring o utiliza automaticamente, sem exigir @Autowired. Essa abordagem explicita a dependência obrigatória e permite fornecer o repositório diretamente ao criar o controller em um teste. Consulte a documentação de injeção do Spring.

Quando há erros, retornamos a view na mesma requisição, preservando os dados e o BindingResult. O redirecionamento acontece depois de salvar.

1.4.1. Validação em cascata e grupos

@Valid permite percorrer uma associação e validar o objeto referenciado. Os grupos selecionam quais restrições serão aplicadas nessa validação.

Cenário: cadastrar e editar uma pessoa com endereço

No cadastro, Pessoa e Endereco são novos e ainda não possuem identificadores. Na edição, ambos já existem e precisam ser identificados. Neste exemplo, editar significa alterar a pessoa e seu endereço existente, sem substituir o endereço por um novo.

Grupo Regras

Cadastro

Não acrescenta restrições aos IDs neste exemplo; o cadastro verifica as regras comuns de Default.

Edicao

Os identificadores devem estar preenchidos.

Default

Nome, logradouro e CEP são obrigatórios nas duas operações.

Crie as interfaces em arquivos separados. Elas apenas identificam os grupos e não precisam de métodos.

Cadastro.java
public interface Cadastro {
}
Edicao.java
public interface Edicao {
}

Definindo as regras e a associação

As classes abaixo isolam o exemplo de validação, com a associação Pessoa.endereco. O mapeamento JPA foi omitido; consulte o capítulo de persistência para @Entity, @Id e @OneToOne. Não crie outra classe com o mesmo nome no mesmo pacote: adapte o exemplo à classe existente. Se os grupos estiverem em outro pacote, importe-os.

Pessoa.java
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

public class Pessoa {

    @NotNull(groups = Edicao.class)
    private Long id;

    @NotBlank(message = "Informe o nome.")
    private String nome;

    @NotNull(message = "Informe o endereço.")
    @Valid
    private Endereco endereco;

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getNome() { return nome; }
    public void setNome(String nome) { this.nome = nome; }

    public Endereco getEndereco() { return endereco; }
    public void setEndereco(Endereco endereco) { this.endereco = endereco; }
}
Endereco.java
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;

public class Endereco {

    @NotNull(groups = Edicao.class)
    private Long id;

    @NotBlank(message = "Informe o logradouro.")
    private String logradouro;

    @NotBlank(message = "Informe o CEP.")
    @Pattern(regexp = "[0-9]{8}", message = "O CEP deve conter oito dígitos.")
    private String cep;

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getLogradouro() { return logradouro; }
    public void setLogradouro(String logradouro) { this.logradouro = logradouro; }

    public String getCep() { return cep; }
    public void setCep(String cep) { this.cep = cep; }
}

As restrições sem groups pertencem a Default. @NotNull exige a presença do endereço; @Valid verifica suas restrições internas. A cascata ignora referências nulas, por isso as duas anotações têm funções complementares.

Sem conversão de grupos, os grupos selecionados para Pessoa também são propagados a Endereco. Assim, na edição, os IDs de ambos os objetos devem estar preenchidos. No cadastro, não há restrição de validação sobre os IDs. Sem message personalizado, @NotNull utiliza a mensagem padrão. Essa cascata de validação não é a cascata de persistência da JPA.

Usando Cadastro e Edicao no controller

Selecione Default junto com o grupo da operação para verificar também os campos comuns:

Operação Anotação no parâmetro

Cadastrar

@Validated({Default.class, Cadastro.class})

Editar

@Validated({Default.class, Edicao.class})

O controller a seguir demonstra somente a validação. Ele retorna os erros ou uma mensagem de dados válidos; a gravação deve ser realizada pelo serviço da aplicação.

ConferenciaPessoaController.java
import jakarta.validation.groups.Default;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.servlet.ModelAndView;

@Controller
public class ConferenciaPessoaController {

    @PostMapping("/pessoas/conferir-cadastro")
    public ModelAndView conferirCadastro(
            @Validated({Default.class, Cadastro.class})
            @ModelAttribute("pessoa") Pessoa pessoa,
            BindingResult result) {

        ModelAndView view = new ModelAndView("pessoas/form-grupos");
        if (result.hasErrors()) {
            return view;
        }

        view.addObject("mensagem", "Dados válidos para cadastro.");
        return view;
    }

    @PostMapping("/pessoas/conferir-edicao")
    public ModelAndView conferirEdicao(
            @Validated({Default.class, Edicao.class})
            @ModelAttribute("pessoa") Pessoa pessoa,
            BindingResult result) {

        ModelAndView view = new ModelAndView("pessoas/form-grupos");
        if (result.hasErrors()) {
            return view;
        }

        view.addObject("mensagem", "Dados válidos para edição.");
        return view;
    }
}

BindingResult fica imediatamente após o objeto validado. Um erro no CEP aparece no caminho endereco.cep. No template templates/pessoas/form-grupos.html, dentro de um formulário com th:object="${pessoa}", apresente-o assim:

<label for="endereco.cep">CEP</label>
<input type="text" th:field="*{endereco.cep}">
<span th:if="${#fields.hasErrors('endereco.cep')}"
      th:errors="*{endereco.cep}"></span>

Complete o formulário com os campos nome e endereco.logradouro. No cadastro, envie o POST para /pessoas/conferir-cadastro, sem IDs. Na edição, use /pessoas/conferir-edicao e envie id e endereco.id em campos type="hidden". A abertura da tela por GET deve colocar pessoa no modelo, com um Endereco inicializado no cadastro ou os dados carregados para edição. A mensagem pode ser exibida com th:text="${mensagem}".

Na operação real de edição, o serviço deve buscar os registros e conferir se o endereço pertence à pessoa. @NotNull verifica apenas o preenchimento do ID, não a existência nem a relação entre os registros.

Selecionando os grupos conforme a operação

Com essas regras, @Validated({Default.class, Cadastro.class, Edicao.class}) também exige os IDs, pois inclui Edicao. Não há conflito entre as restrições, mas essa seleção não serve ao cadastro de novos registros sem ID. Selecione o grupo da operação junto com Default. A interface Cadastro fica disponível para futuras regras específicas; atualmente, selecioná-la junto com Default tem o mesmo efeito de validar apenas Default.

Considere nome, logradouro e CEP válidos:

Dados Default + Cadastro Default + Edicao

Pessoa e endereço sem IDs

Válido

Erros nos dois IDs.

Pessoa com ID 10 e endereço com ID 20

Válido para a validação.

Válido para a validação.

Pessoa com ID 10 e endereço sem ID

Válido para a validação.

Erro no ID do endereço.

Pessoa e endereço sem IDs, mas CEP inválido

Erro em endereco.cep.

Erros nos IDs e em endereco.cep.

Endereço nulo

Erro de endereço obrigatório.

Erro de endereço obrigatório e, se ausente, do ID da pessoa.

Uma regra pode pertencer aos dois grupos com groups = {Cadastro.class, Edicao.class}; isso significa aplicá-la quando qualquer um deles for selecionado. É diferente de solicitar todos os grupos em uma única validação. Para as regras comuns deste exemplo, usamos Default.

1.5. Apresentando os erros com Thymeleaf

No template src/main/resources/templates/pessoas/form.html, associe o formulário a pessoa. th:field vincula os campos; th:errors apresenta as mensagens; th:errorclass adiciona uma classe CSS quando há erro.

Formulário de cadastro
<form th:action="@{/pessoas/save}" th:object="${pessoa}" method="post">
    <label for="nome">Nome</label>
    <input type="text" th:field="*{nome}" th:errorclass="is-invalid">
    <span th:if="${#fields.hasErrors('nome')}" th:errors="*{nome}"></span>

    <label for="idade">Idade</label>
    <input type="number" th:field="*{idade}" th:errorclass="is-invalid">
    <span th:if="${#fields.hasErrors('idade')}" th:errors="*{idade}"></span>

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

A classe is-invalid precisa de uma definição CSS (própria ou de uma biblioteca, como Bootstrap) para alterar a aparência.

Para apresentar um resumo, inclua este trecho dentro do formulário:

<ul th:if="${#fields.hasAnyErrors()}">
    <li th:each="erro : ${#fields.detailedErrors()}">
        <span th:text="${erro.message}"></span>
    </li>
</ul>

Cada erro detalhado possui fieldName (nome/caminho do campo), message (mensagem resolvida) e global (se o erro é do objeto, em vez de um campo específico).

1.5.1. Exibindo erros fora do formulário

Fora de th:object, use o nome do objeto em uma expressão ${…​}:

<div th:if="${#fields.hasErrors('pessoa.*')}"
     th:errors="${pessoa.*}"></div>

1.6. Personalizando mensagens

O atributo message é opcional. Quando ele não é informado, a validação utiliza a mensagem padrão da restrição, como em @NotNull ou @NotBlank. Defina message apenas quando desejar personalizar o texto apresentado ao usuário.

O atributo message permite escrever a mensagem diretamente na restrição, como nos exemplos de Pessoa. Parâmetros como {value} e {max} são substituídos pelos atributos da anotação.

1.6.1. Mensagens do Bean Validation

Para separar as mensagens do código, crie src/main/resources/ValidationMessages.properties e use a chave entre chaves na anotação:

@Min(value = 18, message = "{pessoa.idade.minima}")
private Integer idade;
ValidationMessages.properties
pessoa.idade.minima=A idade deve ser maior ou igual a {value}.

Essa é a convenção do Bean Validation. É possível criar variantes por idioma, como ValidationMessages_pt_BR.properties.

1.6.2. Mensagens do Spring MVC

O Spring também resolve códigos de erro pelo seu MessageSource. Em uma aplicação Spring Boot com a configuração padrão, use src/main/resources/messages.properties para personalizar a apresentação dos erros:

messages.properties
Min.pessoa.idade=A idade deve ser maior ou igual a {1}.
typeMismatch.pessoa.idade=Informe uma idade inteira válida.

No primeiro código, Min identifica a restrição, pessoa é o nome do objeto no modelo e idade é o campo. Nesse erro, {0} representa o campo e {1} recebe o limite de @Min. Já typeMismatch trata uma falha de conversão, não uma restrição do Bean Validation.

ValidationMessages.properties usa parâmetros como {value}; messages.properties, na resolução de erros do Spring, usa argumentos posicionais como {1}. São mecanismos distintos.

1.7. Enviando uma mensagem no redirecionamento

O método save() usa addFlashAttribute() para disponibilizar a mensagem na requisição de destino. Os atributos flash são mantidos temporariamente, normalmente na sessão, e removidos após o uso. addAttribute() é usado para expandir variáveis da URL ou acrescentar parâmetros de consulta.

No template pessoas/list.html, apresente a mensagem:

<p th:if="${mensagem != null}" th:text="${mensagem}"></p>

1.8. Verificando o comportamento

Após integrar os exemplos à aplicação, experimente:

Nome Idade Resultado esperado

Vazio ou apenas espaços

18

Erro de nome obrigatório.

Mais de 100 caracteres

18

Erro de tamanho do nome.

Ana

Vazia

Erro de idade obrigatória.

Ana

17

Erro de idade mínima.

Ana

18

Cadastro e redirecionamento com mensagem de sucesso.

Ana

Texto não numérico

Erro de conversão; nenhuma gravação.

O navegador pode impedir texto em um campo type="number". Para verificar a conversão no servidor, envie uma requisição POST com idade=abc por uma ferramenta HTTP, mantendo os demais requisitos da aplicação.

Restrições HTML, como required e min, podem melhorar a experiência, mas a requisição precisa continuar sendo validada no servidor.

2. Referências