DEV Community

Armando Tadeu
Armando Tadeu

Posted on

Uma API REST com Spring

Esse artigo foi elaborado em uma etapa de desafio do processo seletivo para o programa Orange Talents da Zup.

A tarefa aqui é descrever em formato de artigo, estilo blog post, sobre um projeto em Spring.

O passo a passo foi implementado e o código está aqui: lottery-orange-talents

Contexto

Descrever o passo a passo utilizado para a criação de uma API REST que fornece um sistema de loteria.

A API deverá gerar números aleatórios para a aposta do solicitante, cada número deve estar associado a um e-mail para identificar a pessoa que está concorrendo.

Essencialmente são 2 endpoints, um receberá o e-mail da pessoa e retornará um objeto de resposta com os números sorteados para a aposta e o outro endpoint deverá listar em ordem de criação todas as apostas de um soliciante (passando seu e-mail como parâmetro).

A implementação deverá ser descrita utilizando Java com o ecossistema Spring + Hibernate.

Implementação

Modelagem

Para começar a implementação de uma API REST, após os requisitos, é importante documentar a modelagem do sistema para nortear o desenvolvimento, descrevendo as classes, relacionamentos, etc.

Nesse contexto, irei utilizar modelagem de dados UML mesclando modelagem conceitual com modelagem lógica.

Conceitual = descreve as entidades do sistema independente de paradigma e tecnologia;
Lógica = descreve as entidades, relacionamentos e determina um paradigma como POO, relacional, etc.;

Alt Text

Gerando a aplicação Spring Boot

Para iniciar o projeto, irei utilizar o Spring initializr com as seguintes configurações:

  • Maven como gerenciador de dependências;
  • Versão 2.4.2 do Spring;
  • Java 11;

E adicionarei a dependência Spring Web para utilizar os recursos de uma aplicação web MVC, assim poderei ter acesso a anotações que me auxiliam a criar Controllers, Services, etc. e a aplicação ja poderá rodar em um servidor Apache Tomcat.

O pom.xml inicial ficará assim:

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>2.4.2</version>
  <relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.armandotdelcol</groupId>
<artifactId>lottery-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>lottery-api</name>
<description>Desafio Orange Talents</description>
<properties>
  <java.version>11</java.version>
</properties>
<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
</dependencies>

<build>
  <plugins>
    <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
    </plugin>
  </plugins>
</build>
Enter fullscreen mode Exit fullscreen mode

Padrão Camadas

Para essa aplicação irei utilizar um padrão de camadas, onde implementarei Entidades que representam as tabelas do banco de dados, Repositories que representam a camada de acesso aos dados, services que representam a camada que conversa com os controllers transferindo DTOs.

Criando as entidades

Criarei as entidades no pacote entities, começando pela entidade LotteryTicket.

As entidades serão utilizadas como modelos para a criação das tabelas no banco de dados, por isso, irei precisar adicionar uma forma do projeto Spring fazer o mapeamento das Classes entidades com o BD e preciso especificar qual o Banco de dados é para ele utilizar.

Para fazer o mapeamento utilizarei o Hibernate que é um ORM muito utilizado pela comunidade Java e que implementa as especificações da JPA (Java Persistence Api) que define os padrões a serem seguidos para urtilizar ORM em Java

No ecossistema Spring é muito simples utilizar o Hibernate, basta adicionar uma dependência e o Maven ira fazer o download de todas os Jars necessários para utiliza-lo, eis a dependência:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Essa dependência inclui a JPA API, Implementação JPA com Hibernate como default, JDBC e outras bibliotecas necessárias.

Como banco de dados irei utilizar o H2 que é um banco de dados carregado em memória em tempo de execução, é bem leve e bom para ser utilizado em fase de desenvolvimento antes de conectar a um banco de dados "profissional".

Para utilizar o H2, eis a depedência a ser adicionada no pom.xml:

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>
Enter fullscreen mode Exit fullscreen mode

LotteryTicket

@Entity
@Table(name = "tb_lottery_tickets")
public class LotteryTicket implements Serializable {

    private static final long serialVersionUID = 1L;

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

    @Column(nullable = false)
    private Integer bet1;

    @Column(nullable = false)
    private Integer bet2;

    @Column(nullable = false)
    private Integer bet3;

    @Column(nullable = false)
    private Integer bet4;

    @Column(columnDefinition = "TIMESTAMP WITHOUT TIME ZONE")
    private Instant createdAt;

    public LotteryTicket() {
    }

    public LotteryTicket(Long id, Integer bet1, Integer bet2, Integer bet3, Integer bet4) {
        this.id = id;
        this.bet1 = bet1;
        this.bet2 = bet2;
        this.bet3 = bet3;
        this.bet4 = bet4;
    }

    // OMITIDO AQUI OS GETTERS AND SETTERS DE id, bet1, bet2, bet3 e bet4.

    public Instant getCreatedAt() {
        return createdAt;
    }

    @PrePersist
    public void prePersist() {
        createdAt = Instant.now();
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        LotteryTicket that = (LotteryTicket) o;
        return Objects.equals(id, that.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}
Enter fullscreen mode Exit fullscreen mode

Utilizei anotação @Entity da JPA para indicar que a classe é uma entidade, @Table para customizar o nome da tabela a ser criada, @id e @GeneratedValue para definir a chave primaria autoincrementavél, @Column para utilizar o nullable=false e não aceitar valores nulos...

Para o campo createdAt utilizei um Instant e defini sem timezone para ficar neutro de timezone e para setar a data de crição utilizei um método prePersist que com auxilio da anotação PrePersist da JPA irá salvar um Instant no momento da criação do registro.

Bettor

@Entity
@Table(name = "tb_bettors")
public class Bettor implements Serializable {

    private static final long serialVersionUID = 1L;

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

    @Column(nullable = false)
    private String email;

    public Bettor() {
    }

    public Bettor(Long id, String email) {
        this.id = id;
        this.email = email;
    }

    // OMITINDO GETTERS AND SETTERS E HASHCODE E EQUALS
}
Enter fullscreen mode Exit fullscreen mode

Associações

Adionarei em LotteryTicket a referência para Bettor com a anotação @ManyToOne e @JoinColumn para definir a coluna de associação:

@ManyToOne
@JoinColumn(name = "bettor_id", nullable = false)
private Bettor bettor;
Enter fullscreen mode Exit fullscreen mode

Adicionando os devidos Getters and Setters e colocando para instanciar via construtor também junto aos demais atributos.

Já em Bettor adicionei a anotação @OneToMany com a opção mappedBy referenciando o campo na tabela de tickets:

@OneToMany(mappedBy="bettor")
private Set<LotteryTicket> lotteryTickets;
Enter fullscreen mode Exit fullscreen mode

Nesse caso como é um Set, não adicionei no construtor e nem coloquei um método Setter, apenas o Getter é o ideal para manipular o lotteryTickets...

Verificando a Interação com o BD H2

Nessa próxima etapa irei habilitar a criação automática das tabelas e fazer alguns testes salvando alguns registros nas tabelas...

Primeiro irei criar um perfil de tests em src/main/resources chamado application-test.properties com o seguinte conteúdo para habilitar a conexão com o H2:

spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.username=sa
spring.datasource.password=

spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
Enter fullscreen mode Exit fullscreen mode

e no arquivo application.properties:

spring.profiles.active=test

spring.jpa.open-in-view=false
Enter fullscreen mode Exit fullscreen mode

Ativando o perfil de test e definindo que as transações com o BD serão feitas fechadas dentro das camadas service-repository não deixando as transações abertas na camada de view que no caso será o nosso controller.

Depois irei criar as interfaces da camada Repository utilizando a anotação da JPA para ter acesso a todos os métodos de interação com o banco dados já implementados e prontos para usar:

@Repository
public interface LotteryTicketRepository extends JpaRepository<LotteryTicket, Long> {
}

// E

@Repository
public interface BettorRepository extends JpaRepository<Bettor, Long> {
}
Enter fullscreen mode Exit fullscreen mode

Para testar a interação com o banco de dados, irei criar um arquivo TestDBMain na raiz do pacote com o seguinte código:

public static void main(String[] args) {

    ApplicationContext applicationContext = new SpringApplicationBuilder(LotteryApiApplication.class)
            .web(WebApplicationType.SERVLET)
            .run(args);

    BettorRepository bettorRepository = applicationContext.getBean(BettorRepository.class);
    LotteryTicketRepository lotteryTicketRepository = applicationContext.getBean(LotteryTicketRepository.class);

    Bettor bettor1 = new Bettor();
    bettor1.setEmail("better1@gmail.com");
    bettorRepository.save(bettor1);

    Random betGenerator = new Random();
    LotteryTicket lotteryTicket1 = new LotteryTicket();
    lotteryTicket1.setBet1(betGenerator.nextInt(100));
    lotteryTicket1.setBet2(betGenerator.nextInt(100));
    lotteryTicket1.setBet3(betGenerator.nextInt(100));
    lotteryTicket1.setBet4(betGenerator.nextInt(100));
    lotteryTicket1.setBettor(bettor1);
    lotteryTicketRepository.save(lotteryTicket1);
}
Enter fullscreen mode Exit fullscreen mode

Com a aplicação startada e acessando a url do H2 consigo verificar a criação dos registros e confirmar que a interação com o banco de dados está Ok. Agora vamos para implementação das outras camadas.

Dto

Irei criar uma classe para servir de molde de dados para transitar entre a camada de controller e a camada de service, pensei em criar um TicketLotteryDTO nesse formato:

public class LotteryTicketDTO implements Serializable {

    private static final long serialVersionUID = 1L;

    private Long id;
    private Integer bet1;
    private Integer bet2;
    private Integer bet3;
    private Integer bet4;
    private Bettor bettor;

    public LotteryTicketDTO() {
    }

    public LotteryTicketDTO(Long id, Integer bet1, Integer bet2, Integer bet3, Integer bet4, Bettor bettor) {
        this.id = id;
        this.bet1 = bet1;
        this.bet2 = bet2;
        this.bet3 = bet3;
        this.bet4 = bet4;
        this.bettor = bettor;
    }

    public LotteryTicketDTO(LotteryTicket entity) {
        this.id = entity.getId();
        this.bet1 = entity.getBet1();
        this.bet2 = entity.getBet2();
        this.bet3 = entity.getBet3();
        this.bet4 = entity.getBet4();
        this.bettor = entity.getBettor();
    }

    // OMITINDO OS GETTERS AND SETTERS
}
Enter fullscreen mode Exit fullscreen mode

Services

Na camada services irei criar métodos publicos, um para inserir uma nova aposta no banco e outro para listar as apostas por email.

Precisarei usar os dois repositories já criados, por isso irei usar o recurso de injeção de dependências do Spring com a anotação @Autowired que já cuida de tudo pra mim e disponibiliza os repositories que preciso.

Tanto no método insert como no método findAllByEmail que irei criar vou precisar de uma busca no BD de Bettors por email, por isso precisarei fazer uma modificação no meu BettorRepository:

@Repository
public interface BettorRepository extends JpaRepository<Bettor, Long> {

  @Query("SELECT b FROM Bettor b WHERE b.email=:email")
  public Optional<Bettor> findByEmail(@Param("email") String email);

}
Enter fullscreen mode Exit fullscreen mode

Esse novo método findByEmail não existe por default na implementação do JPA, por isso precisei criá-lo.

Minha classe LotteryTicketService ficou assim:

@Transactional
public LotteryTicketDTO insert(LotteryTicketDTO dto) {
    Random betGenerator = new Random();
    Bettor bettor = getOrCreateBettor(dto.getBettor().getEmail());
    LotteryTicket entity = new LotteryTicket();
    entity.setBet1(betGenerator.nextInt(100));
    entity.setBet2(betGenerator.nextInt(100));
    entity.setBet3(betGenerator.nextInt(100));
    entity.setBet4(betGenerator.nextInt(100));
    entity.setBettor(bettor);
    entity = lotteryTicketRepository.save(entity);
    return new LotteryTicketDTO(entity);
}

@Transactional
public List<LotteryTicketDTO> findAllByEmail(LotteryTicketDTO dto) {
    Optional<Bettor> obj = bettorRepository.findByEmail(dto.getBettor().getEmail());
    Set<LotteryTicket> lotteryTickets = obj.get().getLotteryTickets();
    List<LotteryTicketDTO> dtoList = lotteryTickets.stream().map(x -> new LotteryTicketDTO(x)).collect(Collectors.toList());
    return dtoList;
}

@Transactional
private Bettor getOrCreateBettor(String email) {
    Optional<Bettor> obj = bettorRepository.findByEmail(email);
    try {
        return obj.get();
    } catch (Exception e) {
        Bettor bettor = new Bettor();
        bettor.setEmail(email);
        return bettorRepository.save(bettor);
    }
  }
Enter fullscreen mode Exit fullscreen mode

A anotação @Transactional indica para o Spring que aquele método faz uma interação com o BD em uma transação.

O método getOrCreateBettor garante que não crie um Bettor com email repetido para uma nova aposta e sim adicione apostas ao Bettor.

Controllers

A interação real entre servidor e cliente acontece aqui no controller:

@RestController
@RequestMapping(value = "/lottery_tickets")
public class LotteryTicketsController {

  @Autowired
  private LotteryTicketService lotteryTicketService;

  @GetMapping
  public ResponseEntity<List<LotteryTicketDTO>> findAllByEmail(@RequestBody LotteryTicketDTO dto) {
      List<LotteryTicketDTO> dtoList = lotteryTicketService.findAllByEmail(dto);
      return ResponseEntity.ok(dtoList);
  }

  @PostMapping
  public ResponseEntity<LotteryTicketDTO> insert(@RequestBody LotteryTicketDTO dto) {
      dto = lotteryTicketService.insert(dto);
      return ResponseEntity.ok().body(dto);
  }

}
Enter fullscreen mode Exit fullscreen mode

A anotação @RestController garante que minha classe seja um controlador REST e @ResquestMapping permite que eu customize a rota de acesso pela URI.

Injeto o LotteryTicketService e crio dois métodos um POST para fazer a inserção de uma nova aposta e um GET para listar as apostas por email.

Ao retornar um LotteryTicketDTO ou uma lista deles, tenho um problema de recursão pois ao trazer o Bettor dentro do Json de LotteryTicketDTO, irá ficar tentanto acessar as apostas dentro do Bettor e Bettor dentro da aposta, etc. Para resolver isso adicionei a anotação @JsonIgnore no atributo lotteryTickets da entidade Bettor:

@JsonIgnore
@OneToMany(mappedBy="bettor")
@OrderBy("createdAt DESC")
private Set<LotteryTicket> lotteryTickets;
Enter fullscreen mode Exit fullscreen mode

Por fim os testes finais, com a aplicação startada e um software de requisições RESTs como Insmnia posso fazer um POST:

http://localhost:8080/lottery_tickets:
JSON BODY:

{
    "bettor": {
        "email": "bettor1@gmail.com"
    }
}
Enter fullscreen mode Exit fullscreen mode

Resposta:

{
  "id": 5,
  "bet1": 44,
  "bet2": 86,
  "bet3": 68,
  "bet4": 72,
  "bettor": {
    "id": 1,
    "email": "bettor1@gmail.com"
  }
}
Enter fullscreen mode Exit fullscreen mode

E um GET:

http://localhost:8080/lottery_tickets:
JSON BODY

{
    "bettor": {
        "email": "bettor1@gmail.com"
    }
}
Enter fullscreen mode Exit fullscreen mode

Resposta:

[
  {
    "id": 5,
    "bet1": 44,
    "bet2": 86,
    "bet3": 68,
    "bet4": 72,
    "bettor": {
      "id": 1,
      "email": "bettor1@gmail.com"
    }
  },
  {
    "id": 4,
    "bet1": 13,
    "bet2": 62,
    "bet3": 78,
    "bet4": 1,
    "bettor": {
      "id": 1,
      "email": "bettor1@gmail.com"
    }
  }
]
Enter fullscreen mode Exit fullscreen mode

E assim fica a implementação de um sistema de Loteria (bem simplório) para explorar os recursos e funcionalidades de Java com Spring + Hibernate.

Extra

Tratamento de exceções

No método findAllByEmail do Service vai disparar um erro com status 500 caso o email informado não possua nenhum cadastro como Bettor. Para tratar isso criei uma exception no pacote services.exceptions:

public class ResourceNotFoundException extends RuntimeException{
  public ResourceNotFoundException(String message) {
      super(message);
  }
}
Enter fullscreen mode Exit fullscreen mode

Modifiquei o método para capturar o erro e lançar essa exceção:

@Transactional
public List<LotteryTicketDTO> findAllByEmail(LotteryTicketDTO dto) {
    Optional<Bettor> obj = bettorRepository.findByEmail(dto.getBettor().getEmail());
    Set<LotteryTicket> lotteryTickets = obj.orElseThrow(() -> new ResourceNotFoundException("Entity not found.")).getLotteryTickets();
    List<LotteryTicketDTO> dtoList = lotteryTickets.stream().map(x -> new LotteryTicketDTO(x)).collect(Collectors.toList());
    return dtoList;
}
Enter fullscreen mode Exit fullscreen mode

E criei um StandardError para moldar a mensagem de retorno de erro la em controllers.exceptions:

public class StandardError implements Serializable {

    private static final long serialVersionUID = 1L;

    private Instant timestamp;
    private Integer status;
    private String error;
    private String message;
    private String path;

    public StandardError() {
    }

    // OMITINDO GETTERS AND SETTERS

}
Enter fullscreen mode Exit fullscreen mode

E uma classe de Handler para disparar uma resposta de erro amigavel quand o ResourceNotFoundException ocorrer:

@ControllerAdvice
public class ResourceExceptionHandler {
  @ExceptionHandler(ResourceNotFoundException.class)
  public ResponseEntity<StandardError> entityNotFound(ResourceNotFoundException exception, HttpServletRequest request) {
      HttpStatus status = HttpStatus.NOT_FOUND;
      StandardError error = new StandardError();
      error.setTimestamp(Instant.now());
      error.setStatus(status.value());
      error.setError("Resource Not Found");
      error.setMessage(exception.getMessage());
      error.setPath(request.getRequestURI());

      return ResponseEntity.status(status).body(error);
  }
}
Enter fullscreen mode Exit fullscreen mode

A anotação @ControllerAdvice coloca essa classe de erro sob comando do Spring e ele irá disparar automaticamente assim que ResourceNotFoundException ocorrer.

Verificando Sequência de números repetidos na aposta

Criei um método em LotteryTicketService para gerar os números da aposta, mas antes de atribuir a entidade LotteryTicket eu verifico se o Bettor não possuí uma aposta com a mesma sequencia de números através de um loop for dentro das lotteryTickets do Bettor:

@Transactional
private void generateBets(Bettor bettor, LotteryTicket entity) {
    Boolean allRight = false;
    Random betGenerator = new Random();
    Integer attempedBet1 = betGenerator.nextInt(100);
    Integer attempedBet2 = betGenerator.nextInt(100);
    Integer attempedBet3 = betGenerator.nextInt(100);
    Integer attempedBet4 = betGenerator.nextInt(100);
    Optional<Bettor> obj = bettorRepository.findByEmail(bettor.getEmail());
    Set<LotteryTicket> lotteryTickets = obj.get().getLotteryTickets();
    try {
        for (LotteryTicket lotteryTicket : lotteryTickets) {
            List<Integer> bets = new ArrayList<>();
            bets.add(lotteryTicket.getBet1());
            bets.add(lotteryTicket.getBet2());
            bets.add(lotteryTicket.getBet3());
            bets.add(lotteryTicket.getBet4());
            if (!bets.contains(attempedBet1) &&
                    !bets.contains(attempedBet2) &&
                    !bets.contains(attempedBet3) &&
                    !bets.contains(attempedBet4)) {
                allRight = true;
            }
        }
        if (!allRight) {
            generateBets(bettor, entity);
        } else {
            entity.setBet1(betGenerator.nextInt(100));
            entity.setBet2(betGenerator.nextInt(100));
            entity.setBet3(betGenerator.nextInt(100));
            entity.setBet4(betGenerator.nextInt(100));
        }
    } catch (Exception e) {
        entity.setBet1(betGenerator.nextInt(100));
        entity.setBet2(betGenerator.nextInt(100));
        entity.setBet3(betGenerator.nextInt(100));
        entity.setBet4(betGenerator.nextInt(100));
    }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)