Aula 04 - REST II: Refinando e Aprimorando a API REST com Spring Boot
Nesta aula, vamos dar continuidade ao desenvolvimento da nossa API REST criada na aula anterior. O foco será na aplicação de boas práticas, tratamento de erros, paginação, ordenação, versionamento e documentação automática com Swagger.
Vamos iniciar a abordar os temas por meio da implementação dos exercícios e desafios da aula anterior.
Tenha atenção ao replicar os códigos-fontes mostrados abaixo, pois para fins de brevidade os
import
das dependências das classes foram omitidos.
1. Métodos customizados na JPA
No exercício 1 da Aula 03, foi pedida a criação de um novo endpoint GET em ContactController
que permitisse buscar contatos pelo nome.
Para cumprirmos esse requisito podemos implementar um método searchContactsByName no
ContactController
. A ideia dessa implementação é criar um endpoint GET que recebe o nome
como um parâmetro de URL e retorne uma lista de contatos cujos nomes correspondem parcial ou totalmente
ao termo pesquisado. Isso é útil para implementarmos funcionalidades de busca mais flexíveis e
dinâmicas. O método é demonstrado abaixo.
@GetMapping("/search")
public List<Contact> searchContactsByName(@RequestParam String name) {
return contactRepository.findByNomeContainingIgnoreCase(name);
}
Perceba que o método encapsula a chamada à findByNomeContainingIgnoreCase, no
ContactRepository
.
O Spring Data JPA fornece a possibilidade de criar métodos personalizados na interface de repositório através de Convenções de Nomes (Naming Conventions). Essa abordagem permite criar consultas complexas apenas definindo métodos que sigam um padrão de nomenclatura específico.
Assim, para implementar o método de busca, podemos adicionar o seguinte código na interface
ContactRepository
:
public interface ContactRepository extends JpaRepository<Contact, Long> {
List<Contact> findByNomeContainingIgnoreCase(String nome);
}
🔍 1.1 Como funciona essa Convenção de Nomes?
A criação de métodos customizados se dá pela definição de nomes que indicam ao Spring Data JPA qual consulta deve ser gerada. Isso é feito utilizando palavras-chave específicas que indicam o critério de busca. Alguns exemplos comuns são:
findBy
: Indica que o método será usado para realizar uma busca.Nome
: O nome do atributo que será utilizado na busca. Neste caso,Nome
refere-se ao atributonome
da classeContact
.Containing
: Indica que a busca deve procurar ocorrências que contenham o termo especificado. É equivalente a umLIKE %termo%
em SQL.IgnoreCase
: Indica que a busca deve ignorar maiúsculas e minúsculas.
Essa convenção é poderosa e nos permite criar consultas como:
findByNome(String nome)
→ Busca contatos pelo nome exato.findByNomeAndEmail(String nome, String email)
→ Busca contatos que correspondem a ambos os critérios.findByNomeOrEmail(String nome, String email)
→ Busca contatos que correspondem a pelo menos um dos critérios.findByNomeStartingWith(String prefix)
→ Busca contatos cujos nomes começam com determinado prefixo.findByNomeEndingWith(String sufix)
→ Busca contatos cujos nomes terminam com determinado sufixo.
📌 1.2 Métodos Personalizados sem Convenções de Nome
Caso queiramos implementar um método que não siga essas convenções, podemos usar anotações
@Query
. Isso é útil quando precisamos de consultas mais complexas ou específicas.
Exemplo com HQL:
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@Query("SELECT c FROM Contact c WHERE LOWER(c.nome) LIKE LOWER(CONCAT('%', :nome, '%'))")
List<Contact> searchByName(@Param("nome") String nome);
Esse método produz o mesmo resultado que findByNomeContainingIgnoreCase
, mas oferece mais
controle sobre a query e permite escrever SQL/HQL diretamente. Também é possível utilizar SQL nativo com
nativeQuery = true
.
Exemplo com SQL nativo:
public interface ContactRepository extends JpaRepository<Contact, Long> {
@Query(value = "SELECT * FROM Contact WHERE LOWER(nome) LIKE LOWER(CONCAT('%', :nome, '%'))", nativeQuery = true)
List<Contact> searchByName(@Param("nome") String nome);
}
📌 1.3 Como funciona?
- A anotação
@Query
define a consulta SQL nativa usando o parâmetronativeQuery = true
. - Neste exemplo, estamos fazendo um
LIKE
que ignora maiúsculas e minúsculas (LOWER()
) e busca contatos que contenham o nome informado em qualquer parte do nome. - É importante usar o nome exato da tabela (
Contact
) como ela existe no banco de dados.
Este método fornece controle total sobre a query e é útil quando precisamos usar recursos específicos do banco de dados que não são suportados diretamente pelo JPA.
📖 1.4 Quando usar cada abordagem:
- Convenção de Nomes: Para consultas simples e padrão, como busca por atributos
específicos (
findByNome
,findByEmail
). - @Query: Para consultas complexas com relacionamentos entre múltiplas entidades, ou quando precisamos de controle total sobre a query.
📌 1.5 Exemplo de Requisição:
GET /api/contacts/search?name=joao
Isso retornará todos os contatos cujo nome contenha a palavra joao
em qualquer parte do
nome, ignorando maiúsculas e minúsculas.
2. Implementação do Método PATCH e Tratamento de Exceções
Para darmos continuidade ao conteúdo e abordarmos o tratamento de exceções, relembremos o Exercício 02 da Aula 03, que solicitava a criação de um novo método PATCH que permitisse atualizar parcialmente um contato sem precisar enviar todos os dados - o que é útil quando o cliente deseja modificar apenas um ou mais atributos específicos de um contato, mantendo os demais inalterados. Caso o ID submetido pelo cliente na requisição não fosse encontrado, entretanto, era necessário lançar uma exceção e retornar o status 404 - não encontrado.
Para cumprir parcialmente esse requisito, podemos podemos prosseguir com a implementação do método PATCH
na classe ContactController
, por meio do método updateContactPartial
, que
recebe um ID e um Map<String, String>
contendo os campos a serem atualizados.
@PatchMapping("/{id}")
public Contact updateContactPartial(@PathVariable Long id, @RequestBody Map<String, String> updates) {
Contact contact = contactRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Contato não encontrado: " + id));
updates.forEach((key, value) -> {
switch (key) {
case "nome":
contact.setNome(value);
break;
case "telefone":
contact.setTelefone(value);
break;
case "email":
contact.setEmail(value);
break;
}
});
return contactRepository.save(contact);
}
🔍 2.1 Explicação do Código:
-
Recebimento do ID do Contato:
- A URL da requisição especifica o ID do contato a ser modificado
(
/api/contacts/{id}
). - Se o contato não for encontrado no banco de dados, é lançada a exceção personalizada
ResourceNotFoundException
, retornando um status HTTP 404. Faremos a implementação dessa exceção personalizada na próxima seção.
- A URL da requisição especifica o ID do contato a ser modificado
(
-
Uso do
Map<String, String>
:- O corpo da requisição é recebido como um
Map
, onde:- A chave (
key
) é o nome do atributo a ser modificado (nome
,telefone
ouemail
). - O valor (
value
) é o novo valor que será atribuído ao atributo correspondente.
- A chave (
- Esse formato é útil porque permite que a requisição inclua somente os campos que precisam ser atualizados, sem exigir o envio do objeto completo.
- O corpo da requisição é recebido como um
-
Iteração sobre o
Map
:- A função
updates.forEach()
percorre cada entrada (key
,value
) doMap
. - A estrutura
switch
verifica qual campo deve ser atualizado e o modifica chamando os métodossetNome()
,setTelefone()
ousetEmail()
do objetoContact
.
- A função
-
Persistência dos Dados:
- Após atualizar os campos necessários, o método chama
contactRepository.save(contact)
para salvar as modificações no banco de dados. - O objeto atualizado é retornado como resposta.
- Após atualizar os campos necessários, o método chama
📌 2.2 Exemplo de Requisição:
Suponha que temos um contato com o ID 1
, e desejamos atualizar apenas o email desse contato.
A requisição ficaria assim:
PATCH /api/contacts/1
Content-Type: application/json
{
"email": "novocontato@email.com"
}
Também é possível enviar múltiplos campos para serem atualizados ao mesmo tempo:
PATCH /api/contacts/1
Content-Type: application/json
{
"nome": "João Silva",
"telefone": "99998888"
}
2.3 🔍 Como o método está relacionado ao tratamento de erros?
Caso o ID do contato submetido na requisição não seja encontrado, o método lança uma exceção
ResourceNotFoundException
:
Contact contact = contactRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Contato não encontrado: " + id));
Essa exceção é capturada por outra classe que iremos criar: a GlobalExceptionHandler
,
configurada para retornar uma resposta com código HTTP 404 (Not Found) quando ocorrer
um ResourceNotFoundException
, garantindo que o cliente receba uma mensagem clara sobre o
problema ocorrido. Antes de passarmos à crição dessas duas classes, entretanto, vamos entender
brevemente o uso do orElseThrow()
com Lambda Expression.
O método Faz parte da lógica que busca um contato no banco de dados pelo seu ID, como vimos anteriormente
quando abordamos as Naming Conventions. findById()
é fornecido pela
interface JpaRepository
(que ContactRepository
estende) e retorna um objeto do
tipo Optional<Contact>
.
Optional<Contact> findById(Long id);
Nesse caso o uso do Optional
é importante porque permite que o método retorne um valor
presente (o contato encontrado) ou ausente (Optional.empty()
) se o ID não existir no banco
de dados. O Optional é uma classe introduzida no Java 8, que faz parte do pacote java.util
.
Ele foi criado para lidar com o problema comum de NullPointerException
, proporcionando uma
maneira elegante de representar a presença ou ausência de um valor: em vez de retornar um valor
potencialmente nulo, métodos podem retornar um Optional, que é um contêiner que pode ou não conter um
valor não-nulo. Para saber mais, consulte a Documentação Oficial do
Optional (Java 8).
Para extrair o valor contido no Optional
, utilizamos o método orElseThrow()
,
que é uma forma prática e segura de lidar com possíveis ausências de valor.
2.4 📌 O que é orElseThrow()
?
O método orElseThrow()
é um método da classe Optional<T>
que é utilizado
para:
- Retornar o valor contido no
Optional
se ele estiver presente. - Lançar uma exceção personalizada caso o valor não esteja presente.
O método orElseThrow()
recebe um Supplier como argumento. Um
Supplier
é uma interface funcional que não recebe parâmetros e retorna um objeto
(T
). Ele é frequentemente implementado usando Expressões Lambda, que
tornam o código mais enxuto e legível.
A expressão lambda usada aqui é:
() -> new ResourceNotFoundException("Contato não encontrado: " + id)
- Os parênteses
()
indicam que a função lambda não recebe parâmetros. - A seta
->
indica o início da parte executável da expressão lambda. - A parte
new ResourceNotFoundException("Contato não encontrado: " + id)
é o código executado quando o lambda é invocado. - Neste caso, estamos retornando uma nova instância de
ResourceNotFoundException
.
2.5 📌 Por que usar uma Expressão Lambda aqui?
O método orElseThrow()
exige um argumento que implemente a interface
Supplier
.
Se não utilizássemos uma expressão lambda, a declaração seria mais verbosa,
algo como:
Contact contact = contactRepository.findById(id)
.orElseThrow(new Supplier<ResourceNotFoundException>() {
@Override
public ResourceNotFoundException get() {
return new ResourceNotFoundException("Contato não encontrado: " + id);
}
});
Claramente, a forma lambda é muito mais compacta e legível.
2.6 📌 Ou seja...
Contact contact = contactRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Contato não encontrado: " + id));
- Tenta buscar um
Contact
peloid
fornecido. - Se encontrado, retorna o objeto
Contact
. - Se não encontrado, lança uma exceção
ResourceNotFoundException
com a mensagem personalizada. - A exceção é tratada pelo
GlobalExceptionHandler
e retorna o status HTTP 404 para o cliente.
Agora que entendemos essa linha de código, vamos abordar o uso de Exceptions customizadas e a
implementação de nosso GlobalExceptionHandler
.
2.7 Tratamento de Erros Relacionados ao Método PATCH
O tratamento de exceções na API é feito pela classe GlobalExceptionHandler
, que lida com
diferentes tipos de erros, incluindo a ResourceNotFoundException
. O tratamento de erros é
uma parte essencial de uma API REST bem estruturada. Ele garante que os consumidores da API recebam
mensagens claras e adequadas sobre o que ocorreu, além de evitar a exposição de detalhes internos do
sistema.
No nosso projeto, utilizamos uma classe chamada GlobalExceptionHandler
anotada com
@RestControllerAdvice
. Essa classe intercepta exceções lançadas em qualquer parte da
aplicação e retorna uma resposta adequada para o cliente. Para manter a organização do código vamos
criar um pacote exception, a fim de manter uma estrutura clara e direta.
2.8 📌 Implementação inicial da Classe GlobalExceptionHandler
package br.ifsp.contacts.exception;
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* Trata exceções de recurso não encontrado
*/
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ResponseEntity<Map<String, String>> handleResourceNotFoundException(ResourceNotFoundException exception) {
Map<String, String> errorResponse = new HashMap<>();
errorResponse.put("error", exception.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
}
/**
* Trata exceções genéricas que não foram capturadas pelos handlers anteriores.
*/
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ResponseEntity<Map<String, String>> handleGenericException(Exception exception) {
Map<String, String> errorResponse = new HashMap<>();
errorResponse.put("error", "Erro interno no servidor. Entre em contato com o suporte.");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
}
}
2.9 🔍 Como funciona?
- Os métodos são anotados com
@ExceptionHandler
para especificar quais tipos de exceção devem ser capturados. Ou seja, cada método será chamado quando ocorrer o tipo de exceção para o qual ele está anotado (ResourceNotFoundException.class
,Exception.class
e assim por diante). - A anotação
@RestControllerAdvice
faz com que a classe capture exceções lançadas por qualquer controlador da nossa aplicação. - Os métodos retornam um
ResponseEntity
, uma classe do Spring que permite personalizar completamente a resposta HTTP, incluindo o corpo da mensagem, o status HTTP e os cabeçalhos. Isso proporciona maior controle sobre o que é retornado ao cliente, garantindo que respostas adequadas sejam enviadas para diferentes situações, como sucessos, erros ou requisições inválidas. ResourceNotFoundException
é uma exceção customizada que retorna um código HTTP 404. Vamos definir essa classe a seguir.
2.10 📌 Classe ResourceNotFoundException
package br.ifsp.contacts.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
Essa classe é uma exceção customizada que herda de RuntimeException
. Ela é utilizada para
indicar que um recurso solicitado (por exemplo, um contato que o usuário está tentando acessar) não foi
encontrado no banco de dados. A garantia de que essa exceção resultará em uma resposta HTTP com código
404 (Not Found) ocorre porque o GlobalExceptionHandler possui um
método anotado com @ExceptionHandler(ResourceNotFoundException.class)
. Essa anotação
instrui o Spring a interceptar qualquer exceção do tipo ResourceNotFoundException
lançada
pela aplicação e tratar essa exceção de maneira centralizada, como explicado acima.
O que acontece aqui é o seguinte:
- Interceptação da Exceção: Sempre que uma
ResourceNotFoundException
é lançada em qualquer parte da aplicação, o métodohandleResourceNotFoundException()
é chamado automaticamente. - Personalização da Resposta: O método cria um
ResponseEntity
com um corpo JSON contendo a mensagem de erro e define explicitamente o código de status HTTP como404 (Not Found)
. - Anotação
@ResponseStatus
: Embora o código HTTP já seja definido peloResponseEntity
(HttpStatus.NOT_FOUND
), a anotação@ResponseStatus(HttpStatus.NOT_FOUND)
fornece uma camada extra de garantia que o status apropriado será retornado.
Portanto, a garantia se dá pelo mecanismo centralizado do Spring para captura e tratamento de exceções,
configurado pelo @ExceptionHandler
no GlobalExceptionHandler
.
2.10 🎯 Em resumo...
Quando o método updateContactPartial
utiliza contactRepository.findById()
, ele
lança a exceção ResourceNotFoundException
se o ID não existir. Essa exceção é tratada pelo
método handleResourceNotFoundException
no GlobalExceptionHandler
, retornando
uma resposta com o código HTTP 404 - Not Found.
O tratamento de erros bem estruturado evita que o cliente receba informações internas da aplicação e
padroniza as mensagens de erro. A integração do GlobalExceptionHandler
é essencial para
capturar exceções lançadas pelo método PATCH e devolver respostas amigáveis.
Devemos sempre fazer tratamento de erros na nossa aplicação, especialmente em APIs REST que precisam fornecer respostas claras e adequadas para os clientes que as consomem. Podemos citar os seguintes motivadores para isso:
-
- Melhoria na Experiência do Usuário: Um tratamento adequado de erros garante que o cliente receba mensagens úteis e compreensíveis, em vez de mensagens genéricas ou códigos de status que não explicam o problema.
-
- Segurança: Se erros não forem tratados corretamente, informações sensíveis sobre a aplicação podem ser expostas acidentalmente, como detalhes do banco de dados ou da lógica de negócio.
-
- Facilidade na Depuração: Mensagens de erro claras e específicas facilitam a identificação e correção de problemas, tanto durante o desenvolvimento quanto na manutenção da aplicação.
-
- Conformidade com os Padrões REST: APIs bem projetadas devem retornar
códigos HTTP apropriados (
404
para recurso não encontrado,400
para requisição inválida,500
para erro interno, etc.) e fornecer informações úteis sobre o problema.
- Conformidade com os Padrões REST: APIs bem projetadas devem retornar
códigos HTTP apropriados (
-
- Evitar Que a Aplicação Quebre: Tratamento adequado de erros impede que a aplicação seja interrompida inesperadamente por uma exceção não tratada.
2.11 🚩 O que acontece se não tratarmos erros adequadamente?
- A aplicação pode retornar mensagens genéricas ou códigos HTTP inadequados, dificultando a depuração.
- Pode expor informações sensíveis que deveriam permanecer ocultas.
- Pode quebrar a aplicação se uma exceção crítica não for tratada.
- Pode resultar em má experiência para os usuários e desenvolvedores que consomem a API.
Embora o tratamento de erros não seja obrigatório para que a aplicação funcione, ele é essencial para garantir segurança, clareza, padronização e uma melhor experiência para os usuários e desenvolvedores. Portanto, é considerado uma boa prática fundamental em qualquer aplicação.
O nosso GlobalExceptionHandler
entretanto ainda não está pronto. Brevemente vamos voltar a
inserir novos tratamentos na classe. Antes disso, entretanto, vamos explorar os Desafios 1 e 2 da Aula
03 e os conceitos que envolvem o relacionamento e validação de Entidades.
3.🔍 Criando e validando modelos de Dados
Para continuarmos a abordar os conceitos do desenvolvimento de APIS Rest com Spring Boot, relembremos os Desafios 1 e 2 da Aula 03.
O Desafio 1 tinha como objetivo criar uma nova entidade Address
relacionada
bidirecionalmente com Contact
, implementar um repositório AddressRepository
e
criar um controlador AddressController
para gerenciar endereços. Também era necessário
criar um endpoint GET /api/contacts/{id}/addresses
para listar todos os endereços
associados a um contato específico.
Já o Desafio 2 exigia a adição de validações na entidade Contact
utilizando a anotação
@Valid
. As regras incluem: nome não pode estar vazio, email deve ter um formato válido
(@Email
) e telefone deve ter entre 8 e 15 caracteres. A API deve retornar respostas
adequadas para entradas inválidas.
Para facilitar a explicação e evitar repetição desnecessário, vamos implementar código que atenda ambos
os desafios. Para conseguir implementar esses desafios, entretanto, temos que entender dois conceitos
fundamentais: relacionamento entre Entidades com uso da JPA e a Validação de dados com a
Jakarta Bean Validation
.
3.1 Uma breve introdução ao relacionamento entre Entidades
Relacionamentos de entidades na JPA são fundamentais para representar como os dados interagem entre si no
banco de dados. A JPA (Java Persistence API) fornece suporte para mapeamento de associações entre
entidades usando anotações específicas que permitem definir os tipos de relacionamento que podem ocorrer
entre essas entidades. Os relacionamentos mais comuns são OneToOne, OneToMany, ManyToOne e ManyToMany.
Quando mapeamos uma relação OneToOne, indicamos que uma entidade está associada
exclusivamente a outra entidade. Para isso, utilizamos a anotação @OneToOne
, que pode ser
configurada com o atributo mappedBy
para especificar o lado proprietário da relação.
No relacionamento ManyToOne, uma entidade pode estar associada a várias instâncias de
outra entidade, mas a relação inversa geralmente é OneToMany, ou seja, um único objeto
de uma entidade pode ter várias referências de outra entidade. Por exemplo, em uma aplicação de contatos
e endereços, cada endereço está associado a um único contato, mas um contato pode ter múltiplos
endereços. Isso é representado na JPA com @ManyToOne
na classe Address
e
@OneToMany
na classe Contact
. Além disso, é necessário configurar
adequadamente as anotações @JoinColumn
para definir a chave estrangeira que conecta as
tabelas.
O relacionamento ManyToMany é usado quando múltiplas instâncias de uma entidade podem
estar associadas a múltiplas instâncias de outra. Esse tipo de relacionamento geralmente é mapeado por
meio de uma tabela intermediária que contém as chaves estrangeiras de ambas as entidades relacionadas.
Na JPA, usamos a anotação @ManyToMany
para definir esse tipo de relacionamento, e podemos
usar a propriedade mappedBy
para especificar o lado não proprietário da associação.
É importante entender que os relacionamentos na JPA podem ser configurados para serem unidirecionais ou
bidirecionais. Uma associação unidirecional significa que apenas uma entidade conhece a existência da
outra, enquanto uma associação bidirecional permite que ambas as entidades se conheçam mutuamente, o que
é útil quando queremos acessar dados relacionados de forma mais natural e eficiente. Quando
implementamos um relacionamento bidirecional, precisamos garantir que a sincronização entre os dois
lados do relacionamento seja tratada adequadamente. Isso é feito configurando o atributo
mappedBy
no lado que não é o proprietário da relação, informando à JPA qual entidade é
responsável pelo gerenciamento do relacionamento.
Além disso, ao configurar relacionamentos, é fundamental definir adequadamente o comportamento de
cascade e orphanRemoval, que especificam se operações realizadas em
uma entidade principal devem ser propagadas para as entidades relacionadas. Por exemplo, ao excluir um
contato, podemos querer que todos os endereços associados também sejam removidos automaticamente, o que
é configurado com o uso da propriedade cascade = CascadeType.ALL
e
orphanRemoval = true
na anotação @OneToMany
.
3.2 Validação de dados de forma simplificada
A validação de dados é um processo essencial para garantir que as informações fornecidas por usuários ou sistemas externos sejam corretas, seguras e adequadas antes de serem processadas ou armazenadas. A linguagem Java fornece várias maneiras de realizar validações, mas a abordagem mais comum e eficiente é por meio das anotações de validação fornecidas pelo pacote Jakarta Bean Validation (anteriormente conhecido como Java EE Bean Validation) e integrado ao Spring Framework por meio da biblioteca Hibernate Validator. Esse mecanismo oferece uma maneira declarativa e robusta para validar dados de entrada sem necessidade de escrever código complexo para cada regra de validação.
O Jakarta Bean Validation utiliza anotações que são aplicadas diretamente sobre os atributos das classes,
facilitando o processo de validação e mantendo o código organizado e legível. As anotações mais comuns
incluem @NotNull
, que garante que o valor de um campo não pode ser nulo;
@NotBlank
, que assegura que um campo de texto não é vazio ou apenas contém espaços em
branco; @Size
, que define o tamanho mínimo e máximo permitido para uma string ou coleção;
@Pattern
, que permite especificar uma expressão regular para validação de formato; e
@Email
, que verifica se um dado fornecido corresponde a um formato válido de endereço de
e-mail. Além dessas, existem várias outras anotações específicas que podem ser usadas dependendo das
necessidades da aplicação, podendo ser consultadas em Hibernate
Validator - Definição de Restrições (Documentação Oficial)
A configuração da validação no Spring Boot é bastante simples e, geralmente, basta adicionar a
dependência do Hibernate Validator
no arquivo pom.xml
do projeto.
Ao longo da disciplina vamos, evidentemente, utilizar as validações para o desenvolvimento de APIs com o
Spring Boot. Nesse contexto, o uso da anotação @Valid
nos controladores REST é fundamental
para ativar o mecanismo de validação automática dos dados recebidos. Quando um cliente envia dados para
o servidor, o Spring automaticamente verifica se os dados atendem aos critérios estabelecidos pelas
anotações de validação na entidade ou no DTO (Data Transfer Object, padrão que abordaremos nas próximas
aulas) e, caso algum critério não seja satisfeito, uma exceção é lançada, normalmente a
MethodArgumentNotValidException
. Essa exceção precisa ser tratada por um mecanismo de
tratamento de erros personalizado, como o GlobalExceptionHandler
que implementamos
anteriormente, para que a aplicação possa responder de forma adequada e amigável ao cliente, geralmente
retornando um código de status HTTP 400 (Bad Request) junto com uma mensagem explicativa. Ou seja, a
partir daí basta aplicar as anotações necessárias nos atributos das entidades ou DTOs e não se esquecer
utilizar o @Valid
nos métodos controladores. Além disso, o Spring Boot permite definir
mensagens de erro personalizadas para cada tipo de validação, tornando as respostas da API mais claras e
amigáveis para os consumidores do serviço.
Além das validações padrão fornecidas pela especificação Jakarta Bean Validation, é possível definir
validações personalizadas quando os requisitos do sistema são mais específicos. Para isso, criamos uma
anotação customizada e implementamos um validador que implementa a interface
ConstraintValidator
. Esse mecanismo permite que os desenvolvedores criem suas próprias
regras de validação e as apliquem a campos ou classes inteiras, mantendo a flexibilidade e a
escalabilidade da aplicação. Futuramente exploraremos essa possibilidade na disciplina.
É importante destacar que a validação de dados deve ser realizada tanto no lado do cliente quanto no lado do servidor. Embora bibliotecas de frontend como React e Angular forneçam recursos para validação de formulários, a validação no servidor é indispensável para garantir a segurança e integridade dos dados, pois os clientes podem ser manipulados ou burlados por usuários mal-intencionados. Por isso, as validações feitas no servidor são a principal linha de defesa contra dados inválidos ou maliciosos. Lembrem-se: temos controle real apenas sobre o lado do servidor. O lado cliente é do cliente!
Vamos agora verificar o código que implementa os Desafios 1 e 2 da Aula 03.
🔍4. Análise dos Códigos-fontes
Passemos, agora, a análise dos códigos que implementam os Desafios 1 e 2. Para fins de brevidade e objetividade da explicação, apresentaremos todos em sequência.
- Caso exista algum erro de código-fonte abaixo, avise ao professor! Todas as correções pertinentes serão incorporadas!
4.1 ✅Código da Entidade Address
A classe Address
foi implementada abaixo com os atributos e validações necessárias, de forma
a atender os Desafios 1 e 2. Vamos analisar o código:
@Entity
public class Address {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "A rua não pode estar vazia")
private String rua;
@NotBlank(message = "A cidade não pode estar vazia")
private String cidade;
@NotBlank(message = "O estado não pode estar vazio")
@Size(min = 2, max = 2, message = "O estado deve ter exatamente 2 caracteres (sigla)")
@Pattern(regexp = "[A-Z]{2}", message = "O estado deve ser representado por duas letras maiúsculas")
private String estado;
@NotBlank(message = "O CEP não pode estar vazio")
@Pattern(regexp = "\\d{5}-\\d{3}", message = "O CEP deve estar no formato 99999-999")
private String cep;
@ManyToOne
@JoinColumn(name = "contact_id", nullable = false)
@JsonBackReference
private Contact contact;
}
🔍Analisando o código
- A classe define os atributos exigidos:
rua
,cidade
,estado
,cep
e a relação com oContact
. - Utiliza anotações de validação (
@NotBlank
,@Size
,@Pattern
) para garantir que os dados sejam válidos antes de serem persistidos. - A anotação
@ManyToOne
define a relação com a classeContact
, com a colunacontact_id
no banco de dados. - A anotação
@JsonBackReference
é usada para evitar problemas de referência cíclica ao serializar os objetos (Contact
eAddress
) para JSON. Quando uma entidade possui uma coleção de entidades relacionadas, como acontece em relaçõesOneToMany
, é necessário indicar que a serialização de JSON deve ignorar a referência de volta para o proprietário da relação. Dessa forma, evitamos erros comoStackOverflowException
quando a conversão para JSON é realizada.
Além disso, percebam que por enquanto vamos transitar diretamente a entidade entre as diferentes camadas da aplicação, inclusive expondo dados como o ID das entidades para o client. É por isso também que temos que usar as anotações para evitar problemas de serialização. A solução para isso seria implementar DTOs (Data Transfer Objects). DTOs são classes criadas para transportar dados entre diferentes camadas da aplicação. Quando implementamos DTOs estamos essencialmente separando a representação de dados da API (o que é retornado ou recebido pelo cliente) da modelagem interna da nossa aplicação (as entidades JPA que manipulam o banco de dados). Os DTOs são classes que contêm apenas os dados que você quer expor na API, sem manter relações direcionais ou bidirecionais presentes nas entidades.
Essa abordagem é melhor por alguns motivos:
- Permite a Independência da camada de persistência, onde o que é exposto na API não precisa ter o mesmo formato do banco de dados.
- Evita problemas de serialização cíclica: As entidades JPA podem manter suas relações bidirecionais, mas o Jackson só verá os DTOs.
- Segurança: Permite controlar exatamente o que é exposto ao cliente, sem expor dados sensíveis ou desnecessários.
- Flexibilidade: Facilita a evolução da API sem alterar diretamente a estrutura das entidades no banco de dados.
Na próxima aula faremos essa melhoria no código, mas por enquanto basta que fiquem cientes que a abordagem adotada até aqui é a mais didática, só que não necessariamente a mais adequada.
Evidentemente, também devemos atualizar a classe Contact. A implementação completa pode ser vista a seguir.
4.2 ✅Código atualizado da Entidade Contact
package br.ifsp.contacts.model;
@Entity
public class Contact {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "O nome não pode estar vazio")
private String nome;
@NotBlank(message = "O email não pode estar vazio")
@Email(message = "Formato de email inválido")
private String email;
@NotBlank(message = "O telefone não pode estar vazio")
@Size(min = 8, max = 15, message = "O telefone deve ter entre 8 e 15 caracteres")
@Pattern(regexp = "\\d+", message = "O telefone deve conter apenas números")
private String telefone;
@OneToMany(mappedBy = "contact", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonManagedReference
@NotEmpty(message = "O contato deve ter pelo menos um endereço")
private List<Address> addresses = new ArrayList<>();
public Contact() {
}
public Contact(String nome, String email, String telefone) {
this.nome = nome;
this.email = email;
this.telefone = telefone;
}
public Long getId() {
return id;
}
public String getNome() {
return nome;
}
public void setNome(String nome) {
this.nome = nome;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getTelefone() {
return telefone;
}
public void setTelefone(String telefone) {
this.telefone = telefone;
}
public List<Address> getAddresses() {
return addresses;
}
public void setAddresses(List<Address> addresses) {
if (addresses != null) {
addresses.forEach(address -> address.setContact(this));
if (this.addresses == null) {
this.addresses = new ArrayList<>();
}
this.addresses.clear();
this.addresses.addAll(addresses);
}
}
}
🔍Analisando o código
A classe Contact
é uma entidade JPA que representa um contato na aplicação
e está mapeada para uma tabela no banco de dados por meio da anotação @Entity
. Esta classe
contém diversos atributos, incluindo uma lista de endereços (List<Address> addresses
)
que se relaciona diretamente com a entidade Address
. A seguir, vamos detalhar cada parte da
classe e como ela se relaciona com a classe Address
.
🔍 Atributos da Classe Contact
id
: É o identificador único do contato (Long
) e é gerado automaticamente pelo banco de dados através da anotação@GeneratedValue(strategy = GenerationType.IDENTITY)
.nome
: Nome do contato, com validação para garantir que não seja vazio (@NotBlank
).email
: Endereço de email do contato, validado tanto para não ser vazio (@NotBlank
) quanto para ter um formato válido (@Email
).telefone
: Número de telefone do contato, validado para ter entre 8 e 15 caracteres (@Size
), apenas números (@Pattern
), e não ser vazio (@NotBlank
).addresses
: É uma lista de endereços (Address
) associados a este contato. Essa relação é estabelecida por meio de uma associação OneToMany.
O relacionamento entre Contact
e Address
é bidirecional e
configurado com as anotações @OneToMany
na classe Contact
e
@ManyToOne
na classe Address
, como visto na seção anterior. Vamos ver
detalhamente o mapeamento, que é feito da seguinte maneira:
📌 Na Classe Contact
(Dono do relacionamento):
@OneToMany(mappedBy = "contact", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonManagedReference
@NotEmpty(message = "O contato deve ter pelo menos um endereço")
private List<Address> addresses = new ArrayList<>();
@OneToMany
: Define que um contato pode ter vários endereços.mappedBy = "contact"
: Indica que o relacionamento é mapeado pelo atributocontact
na classeAddress
.cascade = CascadeType.ALL
: Propaga todas as operações (persistência, remoção, atualização) feitas na entidadeContact
para os seus endereços relacionados.orphanRemoval = true
: Remove automaticamente os endereços que são removidos da listaaddresses
.@JsonManagedReference
: Trabalha junto com@JsonBackReference
na classeAddress
para evitar problemas de serialização cíclica (loop infinito) quando os objetos são convertidos para JSON.@NotEmpty
: Garante que o contato sempre tenha pelo menos um endereço associado. Esse tipo de validação garante que cada contato deve ser salvo com pelo menos um endereço válido, evitando registros incompletos.
É importante notar que o método setAddresses()
é implementado de forma a garantir que todos
os endereços associados a este contato estejam sincronizados. Observe o trecho a seguir:
public void setAddresses(List<Address> addresses) {
if (addresses != null) {
addresses.forEach(address -> address.setContact(this));
if (this.addresses == null) {
this.addresses = new ArrayList<>();
}
this.addresses.clear();
this.addresses.addAll(addresses);
}
}
Este método faz o seguinte:
- Associa o contato atual a todos os endereços recebidos na lista: Para cada endereço
fornecido, o método
address.setContact(this)
é chamado para garantir que o relacionamento bidirecional seja atualizado. - Inicializa a lista de endereços se for nula: Garante que a lista nunca será manipulada sem ser inicializada.
- Limpa a lista de endereços atual: Evita duplicação de dados e garante que a lista de endereços seja atualizada completamente.
- Adiciona todos os endereços novos: Depois de limpar a lista, os novos endereços são adicionados, mantendo a consistência.
A partir das duas entidades acima, temos o relacionamento completo. Caso tivéssemos mais entidades e regras de negócio mais complexas, a ideia se manteria a mesma: as validaríamos e relacionaríamos conforme a necessidade se apresentasse.
Passemos agora à análise da nossa classe AddressRepository
.
4.3 ✅Repositório AddressRepository
O repositório foi implementado de forma simples. Vamos conferir:
@Repository
public interface AddressRepository extends JpaRepository<Address, Long> {
List<Address> findByContactId(Long contactId);
}
- A interface estende
JpaRepository<Address, Long>
, o que significa que herda todos os métodos básicos de manipulação (save()
,delete()
,findById()
, etc.). - O método
findByContactId(Long contactId)
é adicionado para recuperar endereços associados a um contato específico. - A convenção de nomes usada (
findByContactId
) é suficiente para que o Spring Data JPA crie a consulta SQL apropriada automaticamente.
Ou seja: até aqui, nenhuma novidade na criação do Repositório! Vamos verificar agora como ficaram nossos controladores.
4.4 ✅Controller AddressController
A classe AddressController
é um controlador REST responsável por manipular requisições HTTP
relacionadas à entidade Address
na aplicação. Ela é anotada com
@RestController
, o que indica que seus métodos retornarão dados diretamente no corpo da
resposta (em formato JSON, por exemplo). Além disso, a anotação
@RequestMapping("/api/addresses")
define o caminho base para todos os endpoints
dentro deste controlador. Vejamos o código abaixo.
package br.ifsp.contacts.controller;
@RestController
@RequestMapping("/api/addresses")
public class AddressController {
@Autowired
private ContactRepository contactRepository;
@Autowired
private AddressRepository addressRepository;
@GetMapping("/contacts/{contactId}")
public List<Address> getAddressesByContact(@PathVariable Long contactId) {
Contact contact = contactRepository.findById(contactId)
.orElseThrow(() -> new ResourceNotFoundException("Contato não encontrado: " + contactId));
return contact.getAddresses();
}
@PostMapping("/contacts/{contactId}")
@ResponseStatus(HttpStatus.CREATED)
public Address createAddress(@PathVariable Long contactId, @RequestBody @Valid Address address) {
Contact contact = contactRepository.findById(contactId)
.orElseThrow(() -> new ResourceNotFoundException("Contato não encontrado: " + contactId));
address.setContact(contact);
return addressRepository.save(address);
}
}
🔍 Analisando o código:
📌 Injeção de Dependências (@Autowired
)
A classe utiliza injeção de dependências para acessar os repositórios ContactRepository
e
AddressRepository
, que são necessários para buscar contatos existentes e salvar novos
endereços. Essa injeção é feita automaticamente pelo Spring por meio da anotação
@Autowired
, como já vimos anteriormente. Nas próximas aulas vamos explorar também a injeção
de dependências por meio do método construtor, que é a alternativa mais recomendada pelos
desenvolvedores do Spring.
📌 Método getAddressesByContact()
@GetMapping("/contacts/{contactId}")
public List<Address> getAddressesByContact(@PathVariable Long contactId) {
Contact contact = contactRepository.findById(contactId)
.orElseThrow(() -> new ResourceNotFoundException("Contato não encontrado: " + contactId));
return contact.getAddresses();
}
Esse método é um endpoint GET
que permite recuperar todos os endereços associados a um
contato específico. Ele é acessível por meio da URL:
GET /api/addresses/contacts/{contactId}
O que acontece nesse método:
- O método recebe um
contactId
como parâmetro de URL e o busca no banco de dados usando ocontactRepository.findById()
que retorna umOptional<Contact>
. - Se o contato não for encontrado, o método
orElseThrow()
lança uma exceçãoResourceNotFoundException
, que é tratada peloGlobalExceptionHandler
e resulta em um retorno com código HTTP 404 (Not Found). - Caso o contato seja encontrado, o método retorna a lista de endereços associados ao contato por meio
do método
contact.getAddresses()
. - A resposta retornada é uma lista de objetos
Address
convertida automaticamente para JSON pelo Jackson. Esse passo é transparente ao usarmos o Spring Boot.
📌 Método createAddress()
@PostMapping("/contacts/{contactId}")
@ResponseStatus(HttpStatus.CREATED)
public Address createAddress(@PathVariable Long contactId, @RequestBody @Valid Address address) {
Contact contact = contactRepository.findById(contactId)
.orElseThrow(() -> new ResourceNotFoundException("Contato não encontrado: " + contactId));
address.setContact(contact);
return addressRepository.save(address);
}
Esse método é um endpoint POST
que permite criar um novo endereço e associá-lo a um contato
existente. Ele é acessível por meio da URL:
POST /api/addresses/contacts/{contactId}
O que acontece nesse método:
- O método recebe um
contactId
como parâmetro de URL e um objetoAddress
como corpo da requisição (@RequestBody
). - A anotação
@Valid
é usada para garantir que o endereço enviado atende a todas as regras de validação definidas na classeAddress
. - O controlador busca o contato correspondente ao
contactId
usandocontactRepository.findById()
. Caso não seja encontrado, é lançada uma exceçãoResourceNotFoundException
. - Se o contato for encontrado, o endereço é associado ao contato usando o método
address.setContact(contact)
. - O endereço é salvo no banco de dados pelo
addressRepository.save(address)
. - A anotação
@ResponseStatus(HttpStatus.CREATED)
indica que, se o endereço for salvo com sucesso, o servidor retornará um código HTTP 201 (Created).
Em resumo...
A classe AddressController
foi implementada utilizando os conceitos já vistos previamente.
Vejamos agora como ficou o código atualizado do nosso ContactController
.
✅ 4.4 Controller ContactController
A classe ContactController
é um controlador REST, responsável por fornecer
os endpoints para manipulação de recursos Contact
. Ela é anotada com
@RestController
, o que indica que seus métodos retornam dados diretamente como respostas
HTTP, geralmente em formato JSON. A anotação @RequestMapping("/api/contacts")
define a URL base para todos os endpoints do controlador.
O controlador utiliza injeção de dependência para receber um objeto do tipo
ContactRepository
, que é responsável por realizar operações de acesso a dados relacionadas
aos contatos (CRUD). Essa injeção é feita automaticamente pelo Spring por meio da anotação
@Autowired
, assim como na classe AddressController
.
Vejamos, abaixo, seu código-fonte.
package br.ifsp.contacts.controller;
@RestController
@RequestMapping("/api/contacts")
@Validated
public class ContactController {
@Autowired
private ContactRepository contactRepository;
@GetMapping
public List<Contact> getAllContacts() {
return contactRepository.findAll();
}
@GetMapping("{id}")
public Contact getContactById(@PathVariable Long id) {
return contactRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Contato não encontrado: " + id));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Contact createContact(@Valid @RequestBody Contact contact) {
return contactRepository.save(contact);
}
@PutMapping("/{id}")
public Contact updateContact(@PathVariable Long id, @Valid @RequestBody Contact updatedContact) {
Contact existingContact = contactRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Contato não encontrado: " + id));
existingContact.setNome(updatedContact.getNome());
existingContact.setEmail(updatedContact.getEmail());
existingContact.setTelefone(updatedContact.getTelefone());
existingContact.setAddresses(updatedContact.getAddresses());
return contactRepository.save(existingContact);
}
@PatchMapping("/{id}")
public Contact updateContactPartial(@PathVariable Long id, @RequestBody Map<String, String> updates) {
Contact contact = contactRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Contato não encontrado: " + id));
updates.forEach((key, value) -> {
switch (key) {
case "nome":
contact.setNome(value);
break;
case "telefone":
contact.setTelefone(value);
break;
case "email":
contact.setEmail(value);
break;
}
});
return contactRepository.save(contact);
}
@DeleteMapping("/{id}")
public void deleteContact(@PathVariable Long id) {
contactRepository.deleteById(id);
}
@GetMapping("/search")
public List<Contact> searchContactsByName(@RequestParam String name) {
return contactRepository.findByNomeContainingIgnoreCase(name);
}
}
📌 Métodos Implementados no Controller
O controlador ContactController
oferece métodos CRUD e um método adicional para busca
personalizada. Vamos detalhá-los:
✅ getAllContacts()
@GetMapping
public List<Contact> getAllContacts() {
return contactRepository.findAll();
}
Este método retorna todos os contatos presentes no banco de dados. É um endpoint GET
acessível por:
GET /api/contacts
A resposta é uma lista de objetos Contact
.
✅ getContactById()
@GetMapping("{id}")
public Contact getContactById(@PathVariable Long id)
Esse método busca um contato específico por seu id
. Ele retorna um erro
404 - Not Found
caso o contato não exista, usando o método orElseThrow()
que
lança a exceção ResourceNotFoundException
. É acessível por:
GET /api/contacts/{id}
Se encontrado, retorna o objeto Contact
correspondente.
✅ createContact()
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Contact createContact(@Valid @RequestBody Contact contact)
Esse método cria um novo contato a partir dos dados enviados no corpo da requisição. A anotação
@Valid
garante que as validações definidas na classe Contact
sejam
respeitadas. É acessível por:
POST /api/contacts
Se o contato for criado com sucesso, retorna o objeto Contact
salvo no banco de dados com o
código 201 - Created
.
✅ updateContact()
@PutMapping("/{id}")
public Contact updateContact(@PathVariable Long id, @Valid @RequestBody Contact updatedContact)
Esse método atualiza todos os dados de um contato existente identificado pelo id
. Caso o
contato não seja encontrado, é lançada uma ResourceNotFoundException
. A atualização é feita
de forma completa, substituindo os dados antigos pelos novos. É acessível por:
PUT /api/contacts/{id}
Retorna o contato atualizado.
✅ updateContactPartial()
@PatchMapping("/{id}")
public Contact updateContactPartial(@PathVariable Long id, @RequestBody Map<String, String> updates)
Este método permite atualizações parciais em um contato. Ele recebe um mapa
(Map<String, String>
) contendo os campos que devem ser atualizados e seus novos
valores. Somente os campos presentes no mapa são modificados; os demais permanecem inalterados. É
acessível por:
PATCH /api/contacts/{id}
Caso o contato não seja encontrado, lança uma ResourceNotFoundException
.
✅ deleteContact()
@DeleteMapping("/{id}")
public void deleteContact(@PathVariable Long id)
Esse método exclui um contato identificado por seu id
. A exclusão é feita por meio do método
deleteById()
do ContactRepository
. Se o contato não existir, o repositório não
realiza nenhuma ação. É acessível por:
DELETE /api/contacts/{id}
Retorna uma resposta sem conteúdo (204 - No Content
) se a exclusão for bem-sucedida.
✅ searchContactsByName()
@GetMapping("/search")
public List<Contact> searchContactsByName(@RequestParam String name)
Esse método realiza uma busca personalizada por nome. Ele utiliza o método
findByNomeContainingIgnoreCase()
do ContactRepository
para encontrar contatos
cujo nome contenha o termo pesquisado, independentemente de maiúsculas ou minúsculas. É acessível por:
GET /api/contacts/search?name=Joao
Retorna uma lista de contatos que correspondem ao critério de busca.
📌 Como os métodos interagem com a camada de persistência
(ContactRepository
)?
O controlador depende diretamente do repositório ContactRepository
para todas as operações
de leitura, escrita, atualização e exclusão. A camada de persistência é configurada para lidar com
entidades JPA (Contact
), o que significa que as operações de banco de dados são tratadas de
forma transparente pelo Spring Data JPA. É a mesmíssima coisa que já fizemos anteriormente. Percebam:
estamos apenas repetindo os mesmos padrões já vistos anteriormente.
📌 Como os métodos tratam erros e validações?
- Validação de Dados: A anotação
@Valid
garante que os objetos enviados na requisição atendam às regras definidas na classeContact
. - Tratamento de Erros: O uso de
ResourceNotFoundException
integrado com oGlobalExceptionHandler
garante que as requisições inválidas sejam respondidas de forma apropriada (404 - Not Found
).
É exatamente a mesma lógica que aplicamos no AddressController
!
✅ 4.5 Melhorando o GlobalExceptionHandler
A implementação inicial do GlobalExceptionHandler
foi projetada para lidar apenas com
exceções genéricas (Exception
) e com uma exceção personalizada específica
(ResourceNotFoundException
). Essa abordagem já proporcionava uma maneira centralizada e
padronizada de tratar erros na aplicação. No entanto, ela carecia de um tratamento mais robusto para
situações comuns no desenvolvimento de APIs REST, especialmente em relação à validação de dados de
entrada.
A implementação inicial era limitada porque:
- Tratava apenas exceções genéricas (
Exception
) eResourceNotFoundException
. - Não possuía suporte para o tratamento explícito de erros de validação.
- Não fornecia mensagens detalhadas sobre quais campos específicos estavam inválidos, o que poderia dificultar a compreensão por parte do cliente da API.
Podemos melhorar a implementação inicial, mostrada na seção 2.8, por meio do código-fonte abaixo
package br.ifsp.contacts.exception;
import jakarta.validation.ConstraintViolationException;
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* Trata erros de validação de entrada (ex: campos inválidos no @Valid)
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException exception) {
Map<String, String> errors = new HashMap<>();
exception.getBindingResult().getAllErrors().forEach((error) -> {
if (error instanceof FieldError) {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
}
});
return ResponseEntity.badRequest().body(errors);
}
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ResponseEntity<Map<String, String>> handleGlobalValidationExceptions(ConstraintViolationException exception) {
Map<String, String> errors = new HashMap<>();
exception.getConstraintViolations().forEach(violation ->
errors.put(violation.getPropertyPath().toString(), violation.getMessage())
);
return ResponseEntity.badRequest().body(errors);
}
/**
* Trata exceções de recurso não encontrado
*/
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ResponseEntity<Map<String, String>> handleResourceNotFoundException(ResourceNotFoundException exception) {
Map<String, String> errorResponse = new HashMap<>();
errorResponse.put("error", exception.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
}
/**
* Trata exceções genéricas que não foram capturadas pelos handlers anteriores.
*/
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ResponseEntity<Map<String, String>> handleGenericException(Exception exception) {
Map<String, String> errorResponse = new HashMap<>();
errorResponse.put("error", "Erro interno no servidor. Entre em contato com o suporte.");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
}
}
📌 O que foi melhorado na implementação final?
Essa implementação melhorada do GlobalExceptionHandler
amplia significativamente o escopo de
tratamento de erros ao adicionar suporte para exceções de validação. Isso foi feito adicionando métodos
específicos para capturar e processar erros relacionados à entrada de dados inválida. Vamos analisar as
melhorias implementadas.
📌 Suporte a MethodArgumentNotValidException
O método handleValidationExceptions()
foi adicionado para tratar exceções do tipo
MethodArgumentNotValidException
. Essas exceções são lançadas quando um objeto validado com
a anotação @Valid
não atende às regras definidas nas entidades, como
@NotBlank
, @Size
, @Pattern
, etc.
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException exception) {
Map<String, String> errors = new HashMap<>();
exception.getBindingResult().getAllErrors().forEach((error) -> {
if (error instanceof FieldError) {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
}
});
return ResponseEntity.badRequest().body(errors);
}
- Este método percorre todos os erros encontrados e os mapeia para um objeto
Map
que associa o nome do campo (fieldName
) com a mensagem de erro (errorMessage
). - A resposta gerada é uma
ResponseEntity
contendo umMap
que detalha todos os erros detectados, retornando o status HTTP400 (Bad Request)
. - Essa implementação melhora a experiência do usuário, fornecendo feedback claro sobre o que precisa ser corrigido.
📌 Suporte a ConstraintViolationException
O método handleGlobalValidationExceptions()
foi adicionado para capturar exceções do tipo
ConstraintViolationException
. Essas exceções geralmente ocorrem quando se tenta validar
dados em controladores que não recebem objetos completos, mas parâmetros individuais, por exemplo, ao
utilizar @RequestParam
ou @PathVariable
com validações @Valid
.
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ResponseEntity<Map<String, String>> handleGlobalValidationExceptions(ConstraintViolationException exception) {
Map<String, String> errors = new HashMap<>();
exception.getConstraintViolations().forEach(violation ->
errors.put(violation.getPropertyPath().toString(), violation.getMessage())
);
return ResponseEntity.badRequest().body(errors);
}
- Este método percorre todas as violações detectadas (
constraintViolations
) e as armazena em umMap
. - A resposta HTTP retornada é
400 (Bad Request)
, apropriada para requisições inválidas. - Este tratamento é útil para capturar erros em parâmetros simples, como um telefone inválido passado diretamente na URL.
📌 Tratamento Genérico para Outras Exceções
O método handleGenericException()
é um fallback para qualquer exceção que não foi capturada
por métodos mais específicos.
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ResponseEntity<Map<String, String>> handleGenericException(Exception exception) {
Map<String, String> errorResponse = new HashMap<>();
errorResponse.put("error", "Erro interno no servidor. Entre em contato com o suporte.");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
}
- A captura genérica de exceções garante que erros inesperados sejam tratados com uma resposta padrão.
- Retorna o status HTTP
500 (Internal Server Error)
, indicando um problema do lado do servidor.
Com isso, temos uma maior cobertura à validação na nossa aplicação, cobrindo todos os casos especificados nos Desafios e Exercícios até o momento!
5. Estrutura do Projeto e testes após a implementação dos exercícios e desafios
Após a implementação das classes acima, nossa estrutura de pacotes ficou da seguinte forma:
.
├── src
│ ├── main
│ │ ├── java
│ │ │ └── br
│ │ │ └── ifsp
│ │ │ └── contacts
│ │ │ ├── ContactsApplication.java
│ │ │ ├── controller
│ │ │ │ └── AddressController.java
│ │ │ │ └── ContactController.java
│ │ │ ├── model
│ │ │ │ └── Address.java
│ │ │ │ └── Contact.java
│ │ │ ├── repository
│ │ │ │ └── AddressRepository.java
│ │ │ │ └── ContactRepository.java
│ │ │ └── exception
│ │ │ └── GlobalExceptionHandler.java
│ │ │ └── ResourceNotFoundException.java
│ │ └── resources
│ │ └── application.properties
│ └── test
│ └── java
└── pom.xml (ou build.gradle)
Vamos ver agora como testar nossa API para garantir que todas as funcionalidades implementadas estão funcionando corretamente!
📌 Teste 1: Criar um Contato (POST /api/contacts
)
No Postman ou Insomnia:
- Método:
POST
- URL:
http://localhost:8080/api/contacts
- Body (JSON):
{
"nome": "João Silva",
"email": "joao.silva@email.com",
"telefone": "11999999999",
"addresses": [
{
"rua": "Rua A",
"cidade": "São Paulo",
"estado": "SP",
"cep": "12345-678"
}
]
}
- Resultado Esperado:
- Código HTTP
201 - Created
- Resposta com o contato criado, incluindo o endereço associado.
- Código HTTP
📌 Teste 2: Listar Todos os Contatos (GET /api/contacts
)
No Postman ou Insomnia:
- Método:
GET
- URL:
http://localhost:8080/api/contacts
- Resultado Esperado:
- Código HTTP
200 - OK
- Resposta com a lista de contatos criados, incluindo seus endereços.
- Código HTTP
📌 Teste 3: Buscar Contato por ID (GET /api/contacts/{id}
)
No Postman ou Insomnia:
- Método:
GET
- URL:
http://localhost:8080/api/contacts/1
(Substitua o1
pelo ID do contato que você deseja consultar) - Resultado Esperado:
- Código HTTP
200 - OK
- Retorno do contato correspondente ao ID especificado.
- Código HTTP
📌 Teste 4: Atualização Parcial de Contato (PATCH /api/contacts/{id}
)
No Postman ou Insomnia:
- Método:
PATCH
- URL:
http://localhost:8080/api/contacts/1
(Substitua o1
pelo ID do contato que você deseja atualizar) - Body (JSON):
{
"nome": "João Silva Jr.",
"telefone": "11988888888"
}
- Resultado Esperado:
- Código HTTP
200 - OK
- Retorno do contato atualizado, refletindo as alterações enviadas.
- Código HTTP
📌 Teste 5: Criar um Endereço para um Contato
(POST /api/addresses/contacts/{contactId}
)
No Postman ou Insomnia:
- Método:
POST
- URL:
http://localhost:8080/api/addresses/contacts/1
(Substitua o1
pelo ID do contato que você deseja associar o endereço) - Body (JSON):
{
"rua": "Rua B",
"cidade": "Rio de Janeiro",
"estado": "RJ",
"cep": "22222-222"
}
- Resultado Esperado:
- Código HTTP
201 - Created
- O endereço deve ser salvo e associado ao contato especificado.
- Código HTTP
📌 Teste 6: Listar Endereços de um Contato
(GET /api/addresses/contacts/{contactId}
)
No Postman ou Insomnia:
- Método:
GET
- URL:
http://localhost:8080/api/addresses/contacts/1
- Resultado Esperado:
- Código HTTP
200 - OK
- Lista dos endereços associados ao contato especificado.
- Código HTTP
📌 Teste 7: Buscar Contatos por Nome (GET /api/contacts/search?name=Joao
)
No Postman ou Insomnia:
- Método:
GET
- URL:
http://localhost:8080/api/contacts/search?name=Joao
- Resultado Esperado:
- Código HTTP
200 - OK
- Retorna uma lista de contatos cujos nomes correspondem parcial ou totalmente ao termo pesquisado.
- Código HTTP
📌 Teste 8: Exclusão de Contato (DELETE /api/contacts/{id}
)
No Postman ou Insomnia:
- Método:
DELETE
- URL:
http://localhost:8080/api/contacts/1
- Resultado Esperado:
- Código HTTP
204 - No Content
- O contato é excluído permanentemente do banco de dados.
- Código HTTP
📌 Teste 9: Teste de Validação (POST /api/contacts
)
Vamos verificar se as validações da entidade Contact
funcionam corretamente.
- Método:
POST
- URL:
http://localhost:8080/api/contacts
- Body (JSON):
{
"nome": "",
"email": "invalidEmail",
"telefone": "123",
"addresses": []
}
- Resultado Esperado:
- Código HTTP
400 - Bad Request
- Mensagens de erro indicando o que está inválido:
- Código HTTP
{
"nome": "O nome não pode estar vazio",
"email": "Formato de email inválido",
"telefone": "O telefone deve ter entre 8 e 15 caracteres",
"addresses": "O contato deve ter pelo menos um endereço"
}
6. Recapitulando!
Nesta aula, aprimoramos a nossa API REST desenvolvida na aula anterior, implementando novos recursos e
adotando boas práticas essenciais para garantir a robustez e a consistência da aplicação. Abordamos
conceitos como consultas customizadas no JPA utilizando convenções de nomes e queries
personalizadas por meio da anotação @Query
. Implementamos métodos PATCH para atualizações
parciais de recursos, utilizando o tipo Map<String, String>
e garantindo que apenas
os campos especificados sejam modificados, mantendo os demais intactos.
O tratamento de erros foi aprimorado com a implementação de um GlobalExceptionHandler
, que
captura e trata exceções específicas, como ResourceNotFoundException
, garantindo respostas
HTTP adequadas e amigáveis. Exploramos a importância do tratamento de erros para garantir segurança,
clareza, padronização e uma melhor experiência do usuário.
Além disso, trabalhamos com relacionamentos de entidades usando JPA, implementando uma
relação bidirecional OneToMany / ManyToOne
entre Contact
e
Address
. Também introduzimos conceitos de validação de dados utilizando a
Jakarta Bean Validation
para garantir que os dados fornecidos pelos clientes sejam válidos
antes de serem processados pela aplicação.
Por fim, implementamos controladores REST (ContactController
e
AddressController
) para manipulação dessas entidades, expondo endpoints que permitem criar,
recuperar, atualizar e excluir contatos e seus respectivos endereços.
Além disso, mencionamos que a forma implementada atualmente é didática, porém pode ser aprimorada. Fizemos uma breve introdução ao conceito de DTOs, o que nos leva aos...
7. Exercícios 🤓
1️⃣ - Implementação de DTOs (Data Transfer Objects)
Atualmente, a API utiliza
entidades diretamente na comunicação entre o cliente e o servidor. Para melhorar a segurança, controle
dos dados expostos e evitar problemas de serialização cíclica, implemente DTOs para Contact
e Address
. Substitua os objetos retornados pelos controladores por DTOs e modifique os
controladores para aceitar DTOs como entrada.
2️⃣ - Persistência em Banco de Dados Relacional
Até agora, estamos utilizando o banco
de dados em memória configurado pelo Spring Data JPA. Altere a aplicação para utilizar um banco de dados
relacional real, como MySQL ou PostgreSQL. Modifique o arquivo application.properties
configurando as propriedades de conexão, e adicione dependências adequadas no pom.xml
.
3️⃣ - Paginação e Ordenação
Implemente paginação e ordenação nos métodos que retornam
listas de contatos e endereços. Utilize a interface Pageable
do Spring Data JPA e crie
endpoints que aceitem parâmetros de paginação e ordenação na URL. Garanta que o resultado seja retornado
de forma paginada, e não como uma lista completa.
4️⃣ - Implementação de Documentação da API com Swagger
Agora que já implementamos
funcionalidades importantes na nossa API REST, é hora de garantir que os usuários da API possam
entendê-la e utilizá-la de forma adequada. Para isso, integre o Swagger, que permitirá a geração
automática de uma documentação interativa e amigável. Adicione as dependências adequadas no
pom.xml
, crie uma Classe de Configuração, documente os Endpoints e Teste a Documentação.
📌 Instruções Finais
- ✅ Para os exercícios práticos (1 a 4) a entrega esperada é o código das novas rotas e prints das requisições no Postman ou Insomnia. Envie um link do GitHub ou um arquivo .zip com o código-fonte por meio do Moodle da disciplina.
- ✅ Para o exercício 4 entregue também prints mostrando o teste da documentação gerada pelo swagger. Adicione os prints ao repositório GIT ou no arquivo .zip juntamente com o código-fonte.
- ✅Teste todas as funcionalidades antes de enviar e garanta que o código está funcionando.