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:

Essa convenção é poderosa e nos permite criar consultas como:

📌 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?

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:

📌 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:

  1. 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.
  2. 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 ou email).
      • O valor (value) é o novo valor que será atribuído ao atributo correspondente.
    • Esse formato é útil porque permite que a requisição inclua somente os campos que precisam ser atualizados, sem exigir o envio do objeto completo.
  3. Iteração sobre o Map:

    • A função updates.forEach() percorre cada entrada (key, value) do Map.
    • A estrutura switch verifica qual campo deve ser atualizado e o modifica chamando os métodos setNome(), setTelefone() ou setEmail() do objeto Contact.
  4. 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.

📌 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:

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)

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));

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?

  1. 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).
  2. A anotação @RestControllerAdvice faz com que a classe capture exceções lançadas por qualquer controlador da nossa aplicação.
  3. 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.
  4. 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:

  1. Interceptação da Exceção: Sempre que uma ResourceNotFoundException é lançada em qualquer parte da aplicação, o método handleResourceNotFoundException() é chamado automaticamente.
  2. 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 como 404 (Not Found).
  3. Anotação @ResponseStatus: Embora o código HTTP já seja definido pelo ResponseEntity (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:

2.11 🚩 O que acontece se não tratarmos erros adequadamente?

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.

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

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:

  1. 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.
  2. Evita problemas de serialização cíclica: As entidades JPA podem manter suas relações bidirecionais, mas o Jackson só verá os DTOs.
  3. Segurança: Permite controlar exatamente o que é exposto ao cliente, sem expor dados sensíveis ou desnecessários.
  4. 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

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<>();

É 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:

  1. 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.
  2. Inicializa a lista de endereços se for nula: Garante que a lista nunca será manipulada sem ser inicializada.
  3. Limpa a lista de endereços atual: Evita duplicação de dados e garante que a lista de endereços seja atualizada completamente.
  4. 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);
}

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:

  1. O método recebe um contactId como parâmetro de URL e o busca no banco de dados usando o contactRepository.findById() que retorna um Optional<Contact>.
  2. Se o contato não for encontrado, o método orElseThrow() lança uma exceção ResourceNotFoundException, que é tratada pelo GlobalExceptionHandler e resulta em um retorno com código HTTP 404 (Not Found).
  3. Caso o contato seja encontrado, o método retorna a lista de endereços associados ao contato por meio do método contact.getAddresses().
  4. 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:

  1. O método recebe um contactId como parâmetro de URL e um objeto Address como corpo da requisição (@RequestBody).
  2. A anotação @Valid é usada para garantir que o endereço enviado atende a todas as regras de validação definidas na classe Address.
  3. O controlador busca o contato correspondente ao contactId usando contactRepository.findById(). Caso não seja encontrado, é lançada uma exceção ResourceNotFoundException.
  4. Se o contato for encontrado, o endereço é associado ao contato usando o método address.setContact(contact).
  5. O endereço é salvo no banco de dados pelo addressRepository.save(address).
  6. 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?

É 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:

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);
}

📌 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);
}

📌 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);
}

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:

  1. Método: POST
  2. URL: http://localhost:8080/api/contacts
  3. 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"
    }
  ]
}
  1. Resultado Esperado:
    • Código HTTP 201 - Created
    • Resposta com o contato criado, incluindo o endereço associado.

📌 Teste 2: Listar Todos os Contatos (GET /api/contacts)

No Postman ou Insomnia:

  1. Método: GET
  2. URL: http://localhost:8080/api/contacts
  3. Resultado Esperado:
    • Código HTTP 200 - OK
    • Resposta com a lista de contatos criados, incluindo seus endereços.

📌 Teste 3: Buscar Contato por ID (GET /api/contacts/{id})

No Postman ou Insomnia:

  1. Método: GET
  2. URL: http://localhost:8080/api/contacts/1 (Substitua o 1 pelo ID do contato que você deseja consultar)
  3. Resultado Esperado:
    • Código HTTP 200 - OK
    • Retorno do contato correspondente ao ID especificado.

📌 Teste 4: Atualização Parcial de Contato (PATCH /api/contacts/{id})

No Postman ou Insomnia:

  1. Método: PATCH
  2. URL: http://localhost:8080/api/contacts/1 (Substitua o 1 pelo ID do contato que você deseja atualizar)
  3. Body (JSON):
{
  "nome": "João Silva Jr.",
  "telefone": "11988888888"
}
  1. Resultado Esperado:
    • Código HTTP 200 - OK
    • Retorno do contato atualizado, refletindo as alterações enviadas.

📌 Teste 5: Criar um Endereço para um Contato (POST /api/addresses/contacts/{contactId})

No Postman ou Insomnia:

  1. Método: POST
  2. URL: http://localhost:8080/api/addresses/contacts/1 (Substitua o 1 pelo ID do contato que você deseja associar o endereço)
  3. Body (JSON):
{
  "rua": "Rua B",
  "cidade": "Rio de Janeiro",
  "estado": "RJ",
  "cep": "22222-222"
}
  1. Resultado Esperado:
    • Código HTTP 201 - Created
    • O endereço deve ser salvo e associado ao contato especificado.

📌 Teste 6: Listar Endereços de um Contato (GET /api/addresses/contacts/{contactId})

No Postman ou Insomnia:

  1. Método: GET
  2. URL: http://localhost:8080/api/addresses/contacts/1
  3. Resultado Esperado:
    • Código HTTP 200 - OK
    • Lista dos endereços associados ao contato especificado.

📌 Teste 7: Buscar Contatos por Nome (GET /api/contacts/search?name=Joao)

No Postman ou Insomnia:

  1. Método: GET
  2. URL: http://localhost:8080/api/contacts/search?name=Joao
  3. Resultado Esperado:
    • Código HTTP 200 - OK
    • Retorna uma lista de contatos cujos nomes correspondem parcial ou totalmente ao termo pesquisado.

📌 Teste 8: Exclusão de Contato (DELETE /api/contacts/{id})

No Postman ou Insomnia:

  1. Método: DELETE
  2. URL: http://localhost:8080/api/contacts/1
  3. Resultado Esperado:
    • Código HTTP 204 - No Content
    • O contato é excluído permanentemente do banco de dados.

📌 Teste 9: Teste de Validação (POST /api/contacts)

Vamos verificar se as validações da entidade Contact funcionam corretamente.

  1. Método: POST
  2. URL: http://localhost:8080/api/contacts
  3. Body (JSON):
{
  "nome": "",
  "email": "invalidEmail",
  "telefone": "123",
  "addresses": []
}
  1. Resultado Esperado:
    • Código HTTP 400 - Bad Request
    • Mensagens de erro indicando o que está inválido:
{
  "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

Bons estudos e mãos à obra! 🛠🔥