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.
<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 |
|---|---|---|
|
Rejeita |
|
|
Rejeita |
|
|
Exige texto com algum caractere que não seja espaço em branco. |
|
|
Limita o tamanho de texto, coleção, mapa ou array. |
|
|
Definem limites numéricos inclusivos. |
|
|
Definem limites decimais; |
|
|
Exigem valor positivo / não negativo. |
|
|
Exigem valor negativo / não positivo. |
|
|
Limita a quantidade de dígitos inteiros e fracionários. |
|
|
Verifica o formato de e-mail. |
|
|
Exige correspondência com uma expressão regular. |
|
|
Exigem data passada / passada ou presente. |
|
|
Exigem data futura / futura ou presente. |
|
|
Exigem valor booleano verdadeiro / falso. |
|
|
Exige |
|
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.
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.
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 |
|---|---|
|
Não acrescenta restrições aos IDs neste exemplo; o cadastro verifica as regras comuns de |
|
Os identificadores devem estar preenchidos. |
|
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.
public interface Cadastro {
}
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.
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; }
}
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 |
|
Editar |
|
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.
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.
<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;
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:
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.