API REST com Spring + Testes com MockMVC + Documentação com Swagger

13 minutos de leitura

Atualizado em:

Introdução

Este post tem como objetivo desenvolver uma API REST 100% funcional com Spring e banco de dados PostgreSQL, o Spring data JPA para facilitar as consultas, o padrão DTO para inclusão e atualização dos dados, listar grandes quantidades de dados paginas, com ordenação e busca, implementar testes de integração para validar nossos endpoints com o MockMVC e gerar a documentação de forma automática com o Swagger.

O código completo pode ser obtido no Github:

Dependências

As dependências na nossa aplicação são:

  • Spring Boot
  • Hibernate
  • Spring data JPA
  • MockMVC
  • Swagger

Todas serão tratadas pelo Maven, segundo o pom.xml abaixo:

<parent> 
       <groupId>org.springframework.boot</groupId> 
       <artifactId>spring-boot-starter-parent</artifactId> 
       <version>2.2.5.RELEASE</version> 
</parent>

<dependencies> 
        <dependency> 
            <groupId>org.springframework.boot</groupId> 
            <artifactId>spring-boot-starter-web</artifactId> 
        </dependency>
        <dependency> 
            <groupId>org.springframework.boot</groupId> 
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency> 
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger2</artifactId>
            <version>2.6.1</version>
        </dependency>
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger-ui</artifactId>
            <version>2.6.1</version>
        </dependency>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.hamcrest</groupId>
            <artifactId>hamcrest-core</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
        </dependency>
    </dependencies>

Spring boot

O Spring boot dispensa a necessidade de um servidor de aplicação (explicito) para executar nossa aplicação, facilitando a execução durante o desenvolvimento e até em produção.

Para isso basta criarmos uma classe com o método main do Java:

@SpringBootApplication
public class Main {
    public static void main(String[] args) {
        SpringApplication.run(Main.class, args);
    }
}

Hibernate e Spring data JPA

Vamos utilizar o Hibernate em conjunto do Spring data JPA para automatizar o mapeamento de nossas classes de domínio.

Primeiramente, vamos criar uma classe Produto.java e usar as anotações necessárias para realizar o mapeamento.

As anotações de @ApiModelProperty serão usadas para a documentação com o Swagger, que veremos mais a frente.

@Entity
@Table(name = "produto")
public class Produto {

    @Id
    @SequenceGenerator(name = "produto_seq", sequenceName = "produto_seq", allocationSize = 1)
    @GeneratedValue(generator = "produto_seq", strategy = GenerationType.AUTO)
    private int id;

    private String nome;

    private double valor;

    @ApiModelProperty(notes = "Identificador do produto")
    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    @ApiModelProperty(notes = "Nome do produto")
    public String getNome() {
        return nome;
    }

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

    @ApiModelProperty(notes = "Valor do produto")
    public double getValor() {
        return valor;
    }

    public void setValor(double valor) {
        this.valor = valor;
    }

}

Depois criaremos um 'Repository', estrutura do Spring data JPA que facilita ainda mais as consultas ao banco de dados, além de ser útil mais a frente durante os testes, onde será possível criar uns repositórios falsos para que não seja necessário acessar o banco de dados durante essa etapa.

No repositório de produto abaixo, criamos dois métodos, um findAll() passando um objeto de paginação, somente pelo nome do método, será inferido uma busca por todos os elementos, e um método de busca com uma consulta personalizada.

O 'Repository' são interfaces, que o Spring irá tratar como injeção de dependências quando forem invocadas mais a frente.

@Repository
public interface ProdutoRepository extends PagingAndSortingRepository<Produto, Integer> {

    public Page<Produto> findAll(Pageable pageable);

    @Query("SELECT p FROM Produto p "
            + "WHERE lower(nome) like %:busca% ")
    public Page<Produto> busca(@Param("busca") String busca, Pageable pageable);

}

Edite o arquivo application.properties com os dados da sua conexão, configurações da api, como o caminho padrão e o modo de inicialização. Devemos criar também o banco de dados e um esquema no PostgreSQL.

#debug
debug=true

#api
server.servlet.context-path=/api

#conexão
javax.persistence.create-database-schemas=true
spring.datasource.url=jdbc:postgresql://127.0.0.1:5432/teste
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.jpa.properties.hibernate.default_schema=api

#define como o hibernate irá se comportar quanto a criação do esquema
#create: Apaga e recria todo o esquema
#update: Atualiza o mapeamento
spring.jpa.hibernate.ddl-auto=update

#define se irá executar o 'data.sql'
#always: Sempre irá executar o data.sql
#never: Nunca irá executar o data.sql
spring.datasource.initialization-mode=never

#codificação do data.sql
spring.datasource.sqlScriptEncoding=UTF-8

Controlador REST

Para disponibilizar nossa API publicamente, vamos criar uma classe ProdutoController.java para colocar todos os endpoints referentes a essa classe. E com as anotações necessárias do Spring.

A anotação @RequestMapping("/produtos") irá definir o caminho padrão desse endpoint, nesse caso /produtos.

Para permitir a invocação de nossos endpoint de outras fontes, como uma aplicação REACT ou Angular, precisamos habilitar o CORS (Cross-Origin Resource Sharing), com a anotação @CrossOrigin.

As anotações @Api e  @ApiOperation servirão para documenta pelo swagger, que veremos mais a frente.

@RestController
@RequestMapping("/produtos")
@CrossOrigin
@Api(tags = "Produtos", description = "API de produtos")
public class ProdutoController {

    private final Logger LOGGER = LoggerFactory.getLogger(this.getClass());

    @Autowired
    ProdutoRepository produtoRepository;

...

}

Acima, temos a estrutura básica de um controlador rest, com as anotações necessárias, preparo uma instância do Logger e uma do Repositório de produtos, que será construído por injeção de dependências pelo próprio Spring.

GET

O primeiro endpoint que vamos criar, será o responsável pela resposta da consulta por todos os produtos, pela anotação @GetMapping() informo que responderá a uma requisição do tipo GET. Defino os parâmetros que essa URL pode receber, todos opcionais, para que possa paginar, ordenar e fazer uma busca.

@ApiOperation(value = "Lista os produtos")
    @GetMapping()
    public Page<Produto> listar(
            @RequestParam(
                    value = "page",
                    required = false,
                    defaultValue = "0") int page,
            @RequestParam(
                    value = "size",
                    required = false,
                    defaultValue = "10") int size,
            @RequestParam(
                    value = "sort",
                    required = false) String sort,
            @RequestParam(
                    value = "q",
                    required = false) String q
    ) {
        Pageable pageable = new PageableFactory(page, size, sort).getPageable();

        Page<Produto> resultPage;

        if (q == null) {
            resultPage = produtoRepository.findAll(pageable);
        } else {
            resultPage = produtoRepository.busca(q.toLowerCase(), pageable);
        }

        return resultPage;
    }

Preciso criar um objeto Pageable para passar para o repositório, para isso criei uma fábrica para facilitar essa criação, a PageableFactory.

Abaixo uma consulta de exemplo para a URL /api/produtos:

{
  "content": [
    {
      "id": 2,
      "nome": "Processador Intel Core i7-9700K",
      "valor": 2454
    },
    {
      "id": 3,
      "nome": "Headset Gamer HyperX Cloud Stinger - HX-HSCS-BK/NA ",
      "valor": 189.37
    },
    {
      "id": 4,
      "nome": "Teclado Mecânico Gamer HyperX Mars, RGB, Switch Outemu Bluem, US - HX-KB3BL3-US/R4 ",
      "valor": 284.11
    },
    {
      "id": 5,
      "nome": "Mouse Logitech M90 Preto 1000DPI ",
      "valor": 26.9
    },
    {
      "id": 6,
      "nome": "Gabinete C3Tech Gamer ATX sem Fonte Preto MT-G50BK",
      "valor": 119.6
    },
    {
      "id": 7,
      "nome": "Headphone Edifier Bluetooth W800BT Preto",
      "valor": 250
    },
    {
      "id": 8,
      "nome": "Kindle Novo Paperwhite, 8GB, Wi-Fi, Preto - AO0705 ",
      "valor": 418.99
    },
    {
      "id": 9,
      "nome": "SSD Kingston A400, 240GB, SATA, Leitura 500MB/s, Gravação 350MB/s - SA400S37/240G ",
      "valor": 166
    },
    {
      "id": 10,
      "nome": "HD Seagate BarraCuda, 1TB, 3.5´, SATA - ST1000DM010",
      "valor": 290
    },
    {
      "id": 11,
      "nome": "Cadeira Gamer DT3sports GT, Black - 10293-5",
      "valor": 552.41
    }
  ],
  "pageable": {
    "sort": {
      "unsorted": true,
      "sorted": false,
      "empty": true
    },
    "pageSize": 10,
    "pageNumber": 0,
    "offset": 0,
    "unpaged": false,
    "paged": true
  },
  "last": false,
  "totalPages": 2,
  "totalElements": 19,
  "numberOfElements": 10,
  "sort": {
    "unsorted": true,
    "sorted": false,
    "empty": true
  },
  "first": true,
  "size": 10,
  "number": 0,
  "empty": false
}

Alguns exemplos de outras consultas possíveis:

  • http://127.0.0.1:8080/api/produtos?page=2
  • http://127.0.0.1:8080/api/produtos?page=1&size=30
  • http://127.0.0.1:8080/api/produtos?q=teclado
  • http://127.0.0.1:8080/api/produtos?sort=valor,asc
  • http://127.0.0.1:8080/api/produtos?sort=valor,desc
  • http://127.0.0.1:8080/api/produtos?page=0&size=3&ort=valor,desc&q=intel

Outro endpoint útil é a listagem de um item pelo seu id, que será feito assim:

 @ApiOperation(value = "Busca um produto pelo id")
    @GetMapping(value = "/{id}")
    public ResponseEntity<Produto> listar(@PathVariable Integer id) {
        Optional<Produto> rastreador = produtoRepository.findById(id);

        if (!rastreador.isPresent()) {
            return ApiError.notFound("Produto não encontrado");
        }

        return new ResponseEntity<>(rastreador.get(), HttpStatus.OK);
    }

POST e PUT

Para inserir ou atualizar um novo produto, vamos primeiramente criar uma classe ProdutoDTO.java.

O padrão DTO (Data transfer object) é uma classe que representa a entidade com apenas os atributos necessários para serem expostos publicamente, no nosso exemplo apenas preciso, na criação ou atualização, informar no nome e valor do produto, e nunca o seu id, por isso a sua classe de DTO não possui esse atributo

public class ProdutoDTO {

    private String nome;
    private Double valor;

    @ApiModelProperty(notes = "Nome do produto")
    public String getNome() {
        return nome;
    }

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

    public Double getValor() {
        return valor;
    }

    @ApiModelProperty(notes = "Valor do produto")
    public void setValor(Double valor) {
        this.valor = valor;
    }
}

Sendo assim, o método que representa essa criação ficará assim:

@ApiOperation(value = "Cria um novo Produto")
    @PostMapping()
    public ResponseEntity<Produto> criar(@RequestBody ProdutoDTO dto, UriComponentsBuilder ucBuilder) {
        try {
            //Crio um objeto da entidade preenchendo com os valores do DTO e validando
            Produto produto = new Produto();

            if (dto.getNome() == null || dto.getNome().length() < 2) {
                return ApiError.badRequest("Informe o nome do produto");
            }
            produto.setNome(dto.getNome());

            if (dto.getValor() == null || dto.getValor() <= 0) {
                return ApiError.badRequest("Valor do produto inválido");
            }
            produto.setValor(dto.getValor());

            Produto novo = produtoRepository.save(produto);

            //Se ocorreu algum erro, retorno esse erro para a API
            if (novo == null) {
                return ApiError.badRequest("Ocorreu algum erro na criação do produto");
            }

            //Se foi criado com sucesso, retorno o objeto criado
            return new ResponseEntity<>(novo, HttpStatus.CREATED);
        } catch (Exception e) {
            LOGGER.error("Erro ao criar um produto", e);
            return ApiError.internalServerError("Ocorreu algum erro na criação do produto");
        }
    }

Repare que informo que o ProdutoDTO é o recurso esperado, logo, quando fizer essa requisição, devo passar um json com esses atributos, por exemplo:

{
"nome": "Teclado Microsoft",
"valor": 124
}

Ainda nesse método, faço as validações necessárias, retornando um erro quando não forem atendidos os requisitos, e por final chamo o repositório pedindo para persistir o novo produto.

No final retorno o produto criado. Esse processo pode ser visto abaixo.

A atualização de um produto segue a mesma lógica, informando na URL o id do mesmo.

 @ApiOperation(value = "Atualiza um Rastreador Equipamento")
    @PutMapping(value = "/{id}")
    public ResponseEntity<Produto> atualizar(@PathVariable("id") int id, @RequestBody ProdutoDTO dto) {
        try {
            Optional<Produto> produtoAtual = produtoRepository.findById(id);

            if (!produtoAtual.isPresent()) {
                return ApiError.notFound("Produto não encontrado");
            }

            if (dto.getNome() != null) {
                if (dto.getNome().length() < 2) {
                    return ApiError.badRequest("Nome do produto inválido");
                }
                produtoAtual.get().setNome(dto.getNome());
            }

            if (dto.getValor() != null) {
                if (dto.getValor() <= 0) {
                    return ApiError.badRequest("Valor do produto inválido");
                }
                produtoAtual.get().setValor(dto.getValor());
            }

            //Atualizo o objeto utilizando o repositório
            Produto atualizado = produtoRepository.save(produtoAtual.get());

            //Se ocorreu algum erro, retorno esse erro para a API
            if (atualizado == null) {
                return ApiError.internalServerError("Erro na atualização do produto");
            }

            //Se foi criado com sucesso, retorno o objeto atualizado
            return new ResponseEntity<>(atualizado, HttpStatus.CREATED);
        } catch (Exception e) {
            LOGGER.error("Erro ao atualizar um produto", e);
            return ApiError.internalServerError("Erro na atualização do produto");
        }
    }

DELETE

A remoção também é bem simples, recebo o id, verifico se existe, e retorno um erro se não existir e removo pelo repositório.

  @ApiOperation(value = "Remove um produto")
    @DeleteMapping(value = "/{id}")
    public ResponseEntity<Produto> deletar(@PathVariable Integer id) {
        Optional<Produto> produto = produtoRepository.findById(id);

        if (!produto.isPresent()) {
            return ApiError.notFound("Produto não encontrado");
        } else {
            produtoRepository.deleteById(id);
        }

        return new ResponseEntity<>(HttpStatus.OK);
    }

Documentação com Swagger

O Swagger irá gerar uma documentação da nossa API de forma automática, olhando as anotações que fizemos nos controladores e nas classes de entidade e DTO.

Para isso vamos criar uma classe SwaggerConfig com as anotações necessárias e informando o pacote onde se encontram os nossos controladores.

@Configuration
@EnableSwagger2
public class SwaggerConfig extends WebMvcConfigurationSupport {

    @Bean
    public Docket api() {
        return new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(apiInfo())
                .select()
                .apis(apis())
                .paths(PathSelectors.any())
                .build();
    }

    private Predicate<RequestHandler> apis() {
        return RequestHandlerSelectors.basePackage("br.com.paulocollares.api.controladores.rest");
    }

    private ApiInfo apiInfo() {

        return new ApiInfoBuilder()
                .title("SPRING REST API")
                .description("Documentação das APIs REST")
                .contact(new Contact("pcollares", "www.paulocollares.com.br", null))
                .build();
    }

    @Override
    protected void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("swagger-ui.html")
                .addResourceLocations("classpath:/META-INF/resources/");

        registry.addResourceHandler("/webjars/**")
                .addResourceLocations("classpath:/META-INF/resources/webjars/");
    }
}

Acessando a URL /api/swagger-ui.html, podemos ver a documentação gerada, como no exemplo abaixo.

No projeto há uma classe, MainController, que redireciona a requisição da  raiz para essa página, ou seja, se acessar http://127.0.0.1:8080/api/ é redirecionado para http://127.0.0.1:8080/api/swagger-ui.html.

Testes

Para testar todos esses endpoins vamos usar o MockMVC para automatizar esse processo. Ele será responsável por invocar e testar o retorno das requisições.

A estrutura inicial da classe ProdutoTest inicia o MockMVC.

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {
    ProdutoController.class
})
public class ProdutoTest {

    //URL base para acesso desse controlador
    private final String BASE_URL = "/produtos";

    //Instância do ObjectMapper para trabalhar com JSON
    private ObjectMapper objectMapper;

    //Controlador REST tratado por meio de injeção de dependências
    @Autowired
    private ProdutoController restController;

    //Instância do MockMVC
    private MockMvc mockMvc;

    //Instância do mock repository
    @MockBean
    private ProdutoRepository mockRepository;

    @Before
    public void setUp() {
        objectMapper = new ObjectMapper();
        mockMvc = MockMvcBuilders
                .standaloneSetup(restController)
                .build();
    }

...

}

Vamos fazer um teste ao endpoint de consulta de um produto, para que não seja necessário acessar p banco de dados, e ter dados salvos no mesmo para realizar os testes, utilizamos do mockito para responder uma requisição ao repositório com um dado falso, e verificamos o retorno do endpoint em si.

  @Test
    public void buscar_id_200() throws Exception {

        Produto produto = new Produto();
        produto.setId(1);
        produto.setNome("Teste");
        produto.setValor(10.0);

        when(mockRepository.findById(1)).thenReturn(Optional.of(produto));

        mockMvc.perform(get(BASE_URL + "/1"))
                .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id", is(1)))
                .andExpect(jsonPath("$.nome", is("Teste")))
                .andExpect(jsonPath("$.valor", is(10.0)));

        verify(mockRepository, times(1)).findById(1);
    }

Essa mesma lógica será usada nos outros endpoins.

@Test
    public void buscar_id_404() throws Exception {
        mockMvc.perform(get(BASE_URL + "/2")).andExpect(status().isNotFound());
    }

    @Test
    public void criar_200() throws Exception {

        ProdutoDTO dto = new ProdutoDTO();
        dto.setNome("Teste");
        dto.setValor(11.0);

        Produto produto = new Produto();
        produto.setId(1);
        produto.setNome(dto.getNome());
        produto.setValor(dto.getValor());

        when(mockRepository.save(any(Produto.class))).thenReturn(produto);

        mockMvc.perform(post(BASE_URL)
                .content(objectMapper.writeValueAsString(dto))
                .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id", is(1)))
                .andExpect(jsonPath("$.nome", is("Teste")))
                .andExpect(jsonPath("$.valor", is(11.0)));

        verify(mockRepository, times(1)).save(any(Produto.class));

    }

    @Test
    public void atualizar_200() throws Exception {

        ProdutoDTO dto = new ProdutoDTO();
        dto.setNome("Teste");
        dto.setValor(11.0);

        Produto produto = new Produto();
        produto.setId(1);
        produto.setNome(dto.getNome());
        produto.setValor(dto.getValor());

        when(mockRepository.findById(1)).thenReturn(Optional.of(produto));
        when(mockRepository.save(any(Produto.class))).thenReturn(produto);

        mockMvc.perform(put(BASE_URL + "/1")
                .content(objectMapper.writeValueAsString(dto))
                .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id", is(1)));
    }

    @Test
    public void deletar_200() throws Exception {

        Produto produto = new Produto();
        produto.setId(1);

        when(mockRepository.findById(1)).thenReturn(Optional.of(produto));

        mockMvc.perform(delete(BASE_URL + "/1"))
                .andExpect(status().isOk());

        verify(mockRepository, times(1)).deleteById(1);
    }

Conclusão

Mostrei nesse post um simples exemplo de uma API REST com Spring, o código completo pode ser encontrado no Github: https://github.com/pcollares/api-rest-spring

Referências

Lista de links com referências para todos os assuntos abordados nesse post.

Conceitos

Spring

Spring data JPA

MockMVC

Swagger

DTO

Paginação e ordenação

[]'s

Deixe um comentário