API completa em Golang - Parte 4

Wiliam V. Joaquim - Dec 21 '23 - - Dev Community

O que vamos fazer?

Na parte 4 do nosso crud, vamos fazer a lógica do nosso service de usuário.

Criando nossas entidades

Vamos primeiro criar nossa entidade de usuário, essa entidade vai ser uma representação dos dados do usuário no banco de dados, a entidade vai ser o que vamos retornar do nosso repository, dessa forma não vamos depender da tipagem gerado pelo sqlc. Precisamos criar nessa etapa para poder deixar o contrato entre service e repository definidos.

Crie um arquivo na pasta entity chamado user_entity.go:

  type UserEntity struct {
    ID        string    `json:"id"`
    Name      string    `json:"name"`
    Email     string    `json:"email"`
    Password  string    `json:"password,omitempty"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
  }
Enter fullscreen mode Exit fullscreen mode

Por hora essa serão os dados da entidade user.

Contrato entre service e repository

Ainda não vamos trabalhar no service, mas precisamos definir o contrato para deixar nosso service caminhando sem o repository estar pronto, os contratos nos ajudam com isso, esse desacoplamento permite mais liberdade de avançar sem precisar implementar outras partes da aplicação, podemos inclusive adiar a escolha do banco de dados a ser utilizado, mas poderíamos tranquilamente finalizar todo o resto da aplicação.

Vamos criar nossas interfaces no user_interface_repository.go:

  func NewUserRepository(db *sql.DB, q *sqlc.Queries) UserRepository {
    return &repository{
      db,
      q,
    }
  }

  type repository struct {
    db      *sql.DB
    queries *sqlc.Queries
  }

  type UserRepository interface {
    CreateUser(ctx context.Context, u *entity.UserEntity) error
    FindUserByEmail(ctx context.Context, email string) (*entity.UserEntity, error)
    FindUserByID(ctx context.Context, id string) (*entity.UserEntity, error)
    UpdateUser(ctx context.Context, u *entity.UserEntity) error
    DeleteUser(ctx context.Context, id string) error
    FindManyUsers(ctx context.Context) ([]entity.UserEntity, error)
    UpdatePassword(ctx context.Context, pass, id string) error
  }
Enter fullscreen mode Exit fullscreen mode

Por enquanto esses serão o métodos que vamos utilizar para o usuário, sempre que precisamos retornamos nossa entidade, no qual é de nosso controle, como já mencionado, poderíamos remover essa abstração e o service chamar diretamente os métodos e tipagem gerados pelo sqlc, mas ficamos amarrados ao sqlc, dessa forma ficaria mais tranquilo remover o sqlc caso fosse necessário.

Implementando o UserRepository:

  func (r *repository) CreateUser(ctx context.Context, u *entity.UserEntity) error {
    return nil
  }

  func (r *repository) FindUserByEmail(ctx context.Context, email string) (*entity.UserEntity, error) {
    return nil, nil
  }

  func (r *repository) FindUserByID(ctx context.Context, id string) (*entity.UserEntity, error) {
    return nil, nil
  }

  func (r *repository) UpdateUser(ctx context.Context, u *entity.UserEntity) error {
    return nil
  }

  func (r *repository) DeleteUser(ctx context.Context, id string) error {
    return nil
  }

  func (r *repository) FindManyUsers(ctx context.Context) ([]entity.UserEntity, error) {
    return nil, nil
  }

  func (r *repository) UpdatePassword(ctx context.Context, pass, id string) error {
    return nil
  }
Enter fullscreen mode Exit fullscreen mode

Como não vamos trabalhar nessa camada agora, apenas implemente o contrato para o Go parar de acusar o erro.

Implementando o service

Agora vamos partir para a nossa regra de negócio, o service é a camada mais importante, nela que vamos definir o comportamento da nossa aplicação, no handler apenas tratamos a entrada do dado, não manipulamos nada, e o repository vai servir apenas para persistir e buscar dados no banco.

Criando o usuário

Primeira coisa que precisamos fazer é verificar se o e-mail do usuário que vamos cadastrar já existe no banco, pois nosso campo de e-mail no banco será único

  userExists, err := s.repo.FindUserByEmail(ctx, u.Email)
  if err != nil {
    slog.Error("error to search user by email", "err", err, slog.String("package", "userservice"))
    return err
  }
  if userExists != nil {
    slog.Error("user already exists", slog.String("package", "userservice"))
    return errors.New("user already exists")
  }
Enter fullscreen mode Exit fullscreen mode

Se a busca do usuário por e-mail retornar um erro, retornamos esse erro, se o userExists for diferente de nil, significa que encontrou um usuário, então não podemos deixar prosseguir, retornamos um novo erro errors.New("user already exists"), ainda vamos tratar esse erro no handler e retornar um status code correto.

  passwordEncrypted, err := bcrypt.GenerateFromPassword([]byte(u.Password), 12)
  if err != nil {
    slog.Error("error to encrypt password", "err", err, slog.String("package", "userservice"))
    return errors.New("error to encrypt password")
  }
Enter fullscreen mode Exit fullscreen mode

Agora vamos precisar encriptar a senha do usuário, usando o pacote nativo do Go chamado bcrypt, transformamos a senha do usuário que está no u.Password em um slice de bytes e informamos que a força da encriptação da senha é 12, por padrão o DefaultCost é 10, quanto maior esse número mais forte é sua encriptação, o máximo aceito é 31, lembrando que encriptar tem um custo computacional, quanto maior esse número mais tempo vai levar.

Por curiosidade usando o valor 12 demora cerca de 100ms (Milissegundo) para encriptar, colocando 31 demora cerca de 7 minutos, claro que depende do hardware que está fazendo isso, mas a diferença de tempo é muito grande. Por padrão o uso comum usamos entre 10 e 14.

Agora vamos transformar os dados recebido para a nossa estrutura do UserEntity:

  newUser := entity.UserEntity{
    ID:        uuid.New().String(),
    Name:      u.Name,
    Email:     u.Email,
    Password:  string(passwordEncrypted),
    CreatedAt: time.Now(),
    UpdatedAt: time.Now(),
  }
Enter fullscreen mode Exit fullscreen mode

Para criar o id do tipo uuid usamos o pacote da google o mesmo que usamos para validar um uuid no handler. Outra vantagem de remover a responsabilidade da criação do id do banco de dados, é que agora já sabemos o id do desse usuário sem salvar no banco, isso tem inúmeras vantagem.

Por fim vamos chamar nosso repository e salvar o usuário no banco:

  err = s.repo.CreateUser(ctx, &newUser)
  if err != nil {
    slog.Error("error to create user", "err", err, slog.String("package", "userservice"))
    return err
  }
Enter fullscreen mode Exit fullscreen mode

Apenas isso é suficiente para criar um usuário, ainda vamos adicionar o endereço do usuário ainda na parte 4, e vamos alterar o CreateUser.

Atualizando o usuário

Vai ser parecido com a criação, mas primeiro vamos verificar se o usuário existe pelo id:

  userExists, err := s.repo.FindUserByID(ctx, id)
  if err != nil {
    slog.Error("error to search user by id", "err", err, slog.String("package", "userservice"))
    return err
  }
  if userExists == nil {
    slog.Error("user not found", slog.String("package", "userservice"))
    return errors.New("user already exists")
  }
Enter fullscreen mode Exit fullscreen mode

A lógica inverte, na criação se o userExists for diferente de nil retornamos um erro, no update se o userExists for igual a nil, retornamos um erro, pois significa que não encontramos o usuário que o client que atualizar.

Tem um detalhe, caso o e-mail seja informado:

  if u.Email != "" {
    verifyUserEmail, err := s.repo.FindUserByEmail(ctx, u.Email)
    if err != nil {
      slog.Error("error to search user by email", "err", err, slog.String("package", "userservice"))
      return err
    }
    if verifyUserEmail != nil {
      slog.Error("user already exists", slog.String("package", "userservice"))
      return errors.New("user already exists")
    }
  }
Enter fullscreen mode Exit fullscreen mode

Caso o client precise atualizar o e-mail, antes precisamos verificar se o novo e-mail não está sendo utilizado por outro usuário, fazemos isso no código acima.

Por fim, vamos passar tudo isso para a struct UserEntity e chamar nosso repository:

  updateUser := entity.UserEntity{
    ID:        id,
    Name:      u.Name,
    Email:     u.Email,
    UpdatedAt: time.Now(),
  }
  err = s.repo.UpdateUser(ctx, &updateUser)
  if err != nil {
    slog.Error("error to update user", "err", err, slog.String("package", "userservice"))
    return err
  }
  return nil
Enter fullscreen mode Exit fullscreen mode

E retornamos nil se todo ocorrer corretamente, perceba que na criação, atualização, deletar usuário retornamos sempre nil, na nossa aplicação não tem sentido retornar dados para o cliente, pois são os mesmos dados que acabamos de receber do client, mas existem casos que faz sentido retornar após nossa api processar os dados, tudo depende da sua regra.

Detalhes do usuário

Vamos buscar os dados do usuário pelo id.

 userExists, err := s.repo.FindUserByID(ctx, id)
  if err != nil {
    slog.Error("error to search user by id", "err", err, slog.String("package", "userservice"))
    return nil, err
  }
  if userExists == nil {
    slog.Error("user not found", slog.String("package", "userservice"))
    return nil, errors.New("user not found")
  }
Enter fullscreen mode Exit fullscreen mode

Mesma lógica que já fizemos acima, porém agora nosso service GetUserByID retorna um *response.UserResponse, então precisamos passar nosso UserEntity retornado pelo repository para UserResponse:

  user := response.UserResponse{
    ID:        userExists.ID,
    Name:      userExists.Name,
    Email:     userExists.Email,
    CreatedAt: userExists.CreatedAt,
    UpdatedAt: userExists.UpdatedAt,
  }
  return &user, nil
Enter fullscreen mode Exit fullscreen mode

E pronto, isso é o suficiente!

Deletando o usuário

Essa deve ser a mais simples de todas:

  userExists, err := s.repo.FindUserByID(ctx, id)
  if err != nil {
    slog.Error("error to search user by id", "err", err, slog.String("package", "userservice"))
    return err
  }
  if userExists == nil {
    slog.Error("user not found", slog.String("package", "userservice"))
    return errors.New("user not found")
  }
  err = s.repo.DeleteUser(ctx, id)
  if err != nil {
    slog.Error("error to delete user", "err", err, slog.String("package", "userservice"))
    return err
  }
  return nil
Enter fullscreen mode Exit fullscreen mode

Apenas verificamos se o usuário existe e depois chamamos o repository.

Listar todos os usuários

Esse método também é bem simples.

  findManyUsers, err := s.repo.FindManyUsers(ctx)
  if err != nil {
    slog.Error("error to find many users", "err", err, slog.String("package", "userservice"))
    return nil, err
  }
  users := response.ManyUsersResponse{}
  for _, user := range findManyUsers {
    userResponse := response.UserResponse{
      ID:        user.ID,
      Name:      user.Name,
      Email:     user.Email,
      CreatedAt: user.CreatedAt,
      UpdatedAt: user.UpdatedAt,
    }
    users.Users = append(users.Users, userResponse)
  }
  return &users, nil
Enter fullscreen mode Exit fullscreen mode

Como nosso repository retorna um slice do []entity.UserEntity, precisamos fazer um loop e transformar em response.ManyUsersResponse, é o que fazemos acima.

Alterando a senha

Por último, vamos atualizar a senha.

Primeiro fazemos a validação padrão se o usuário existe.

  userExists, err := s.repo.FindUserByID(ctx, id)
  if err != nil {
    slog.Error("error to search user by id", "err", err, slog.String("package", "userservice"))
    return err
  }
  if userExists == nil {
    slog.Error("user not found", slog.String("package", "userservice"))
    return errors.New("user not found")
  }
Enter fullscreen mode Exit fullscreen mode

Agora precisamos verificar se a senha que temos no banco de dados é a mesma senha que o usuário informou como sua senha antiga, que usamos no nosso dto UpdateUserPasswordDto, se for diferente já retornamos um erro:

  // compare passwords
  err = bcrypt.CompareHashAndPassword([]byte(userExists.Password), []byte(u.OldPassword))
  if err != nil {
    slog.Error("invalid password", slog.String("package", "userservice"))
    return errors.New("invalid password")
  }
Enter fullscreen mode Exit fullscreen mode

Agora vamos adicionar mais um verificação de segurança, para impedir que o usuário troque uma senha por outra exatamente igua, mesma lógica acima, mas agora comparando com a senha do banco e a nova senha que o usuário informou:

  // compare new password with password in database
  err = bcrypt.CompareHashAndPassword([]byte(userExists.Password), []byte(u.Password))
  if err == nil {
    slog.Error("new password is equal to old password", slog.String("package", "userservice"))
    return errors.New("new password is equal to old password")
  }
Enter fullscreen mode Exit fullscreen mode

Por fim, vamos encriptar a nova senha do usuário e salvar no banco, igual o que fizemos na criação do usuário, porém criei um método apenas para salvar a senha do usuário no banco:

  passwordEncrypted, err := bcrypt.GenerateFromPassword([]byte(u.Password), 12)
  if err != nil {
    slog.Error("error to encrypt password", "err", err, slog.String("package", "userservice"))
    return errors.New("error to encrypt password")
  }
  err = s.repo.UpdatePassword(ctx, string(passwordEncrypted), id)
  if err != nil {
    slog.Error("error to update password", "err", err, slog.String("package", "userservice"))
    return err
  }
  return nil
Enter fullscreen mode Exit fullscreen mode

Melhorando erros do handler

Nosso service retorna alguns erros que precisamos melhorar no nosso handler, como "user not found", para isso vamos adicionar esse tratamento no user_handler.go:

  err = h.service.UpdateUser(r.Context(), req, id)
  if err != nil {
    slog.Error(fmt.Sprintf("error to update user: %v", err), slog.String("package", "handler_user"))
    if err.Error() == "user not found" {
      w.WriteHeader(http.StatusNotFound)
      msg := httperr.NewNotFoundError("user not found")
      json.NewEncoder(w).Encode(msg)
      return
    }
    w.WriteHeader(http.StatusBadRequest)
    msg := httperr.NewBadRequestError("error to update user")
    json.NewEncoder(w).Encode(msg)
    return
  }
Enter fullscreen mode Exit fullscreen mode

Dentro do tratamento do erro da chamada ao service, vamos adicionar um novo if, caso o erro seja do tipo "user not found" retornamos um erro com no nosso httperr do tipo NotFoundError.

Você pode alterar essa forma de tratar os erros, para evitar ficar tratando isso no handler, como por exemplo no service retornar já um erro do tipo *http.Request, assim no handler poderia apenas retornar o erro e o service que seria responsável por determinar o tipo do erro, status code e tudo mais, porém prefiro separar e deixar no handler a responsabilidade do http.

Você vai precisar colocar esse tratamento nos métodos do handler: UpdateUser, GetUserByID, DeleteUser, UpdateUserPassword.

Salvando o endereço

Agora vamos salvar o endereço do usuário, para isso vamos utilizar a api do viacep, vamos primeiro atualizar nosso dto CreateUserDto e UpdateUserDto:

  type CreateUserDto struct {
    Name     string `json:"name" validate:"required,min=3,max=30"`
    Email    string `json:"email" validate:"required,email"`
    Password string `json:"password" validate:"required,min=8,max=30,containsany=!@#$%*"`
    CEP      string `json:"cep" validate:"required,min=8,max=8"`
  }

  type UpdateUserDto struct {
    Name  string `json:"name" validate:"omitempty,min=3,max=30"`
    Email string `json:"email" validate:"omitempty,email"`
    CEP   string `json:"cep" validate:"omitempty,min=8,max=8"`
  }
Enter fullscreen mode Exit fullscreen mode

Adicionando o campo CEP, com a validação simples de no mínimo e máximo 8 caracteres, como o nosso validator ValidateHttpData já tem o case min e max não precisamos fazer nada, já está funcionando a validação.

Vamos separar a chamada a api do viacep em uma função, vamos criar na raiz do projeto uma pasta chamada api e dentro dela outra pasta chamada viacep, na pasta api vamos salvar todo o código que for chamar api de terceiros, agora crie um arquivo chamado viacep.go dentro da pasta api/viacep:

  package viacep

  import (
    "encoding/json"
    "fmt"
    "net/http"

    "github.com/wiliamvj/api-users-golang/config/env"
  )

  type ViaCepResponse struct {
    CEP         string `json:"cep"`
    Logradouro  string `json:"logradouro"`
    Complemento string `json:"complemento"`
    Bairro      string `json:"bairro"`
    Localidade  string `json:"localidade"`
    UF          string `json:"uf"`
    IBGE        string `json:"ibge"`
    GIA         string `json:"gia"`
    DDD         string `json:"ddd"`
    SIAFI       string `json:"siafi"`
  }

  func GetCep(cep string) (*ViaCepResponse, error) {
    url := fmt.Sprintf("%s/%s/json", env.Env.ViaCepURL, cep)
    var viaCepResponse ViaCepResponse

    resp, err := http.Get(url)
    if err != nil {
      return nil, err
    }
    defer resp.Body.Close()

    err = json.NewDecoder(resp.Body).Decode(&viaCepResponse)
    if err != nil {
      return nil, err
    }
    if viaCepResponse.CEP == "" {
      return nil, fmt.Errorf("cep not found")
    }
    return &viaCepResponse, nil
  }
Enter fullscreen mode Exit fullscreen mode

Primeiro criamos um struct para deixar com a tipagem da resposta do viacep, depois criamos uma função chamada GetCep que recebe uma cep do tipo string e retorna um ponteiro do *ViaCepResponse ou um error, depois montamos a url usando o fmt.Sprintf, o ViaCepURL colocamos em uma váriavel, você pode colocar a url diretamente se preferir, coloquei em uma env para treinar como utilizar a env com viper, precisa colocar o valor na env:

  VIA_CEP_URL="https://viacep.com.br/ws"
Enter fullscreen mode Exit fullscreen mode

Precisamos importar usando viper na nossa config:

  type config struct {
    GoEnv       string `mapstructure:"GO_ENV"`
    GoPort      string `mapstructure:"GO_PORT"`
    DatabaseURL string `mapstructure:"DATABASE_URL"`
    ViaCepURL   string `mapstructure:"VIA_CEP_URL"`
  }
Enter fullscreen mode Exit fullscreen mode

Depois fazemos a chamada http usando o pacote do Go, por fim transformamos o body recebido na nossa struct ViaCepResponse.

Precisamos agora alterar nosso entity, para adicionar o endereço:

  type UserEntity struct {
    ID        string      `json:"id"`
    Name      string      `json:"name"`
    Email     string      `json:"email"`
    Password  string      `json:"password,omitempty"`
    Address   UserAddress `json:"address,omitempty"`
    CreatedAt time.Time   `json:"created_at"`
    UpdatedAt time.Time   `json:"updated_at"`
  }

  type UserAddress struct {
    CEP        string `json:"cep"`
    IBGE       string `json:"ibge"`
    UF         string `json:"uf"`
    City       string `json:"city"`
    Complement string `json:"complement,omitempty"`
    Street     string `json:"street"`
  }
Enter fullscreen mode Exit fullscreen mode

Não precisamos alterar nada no nosso handler, somente no nosso service, vamos lá! Vamos alterar somente o CreateUser e UpdateUser.

CreateUser:

  cep, err := viacep.GetCep(u.CEP)
  if err != nil {
    slog.Error("error to get cep", "err", err, slog.String("package", "userservice"))
    return err
  }
  newUser := entity.UserEntity{
    ID:       uuid.New().String(),
    Name:     u.Name,
    Email:    u.Email,
    Password: string(passwordEncrypted),
    Address: entity.UserAddress{
      CEP:        cep.CEP,
      IBGE:       cep.IBGE,
      UF:         cep.UF,
      City:       cep.Localidade,
      Complement: cep.Complemento,
      Street:     cep.Logradouro,
    },
    CreatedAt: time.Now(),
    UpdatedAt: time.Now(),
  }
Enter fullscreen mode Exit fullscreen mode

Primeiro vamos pegar o cep usando a função que criamos, caso retorne um erro paramos ali, depois apenas colocamos o retorno do cep na struct newUser e pronto, isso é suficiente.

O UpdateUser que vai ter um pouco mais de alteração, primeiro não vamos mais inicializar e já atribuir valor do UserEntity diretamente igual estávamos fazendo, vamos primeiro declarar:

  var updateUser entity.UserEntity
  if u.Email != "" {
    verifyUserEmail, err := s.repo.FindUserByEmail(ctx, u.Email)
    if err != nil {
      slog.Error("error to search user by email", "err", err, slog.String("package", "userservice"))
      return err
    }
    if verifyUserEmail != nil {
      slog.Error("user already exists", slog.String("package", "userservice"))
      return errors.New("user already exists")
    }
    updateUser.Email = u.Email
  }
  if u.CEP != "" {
    cep, err := viacep.GetCep(u.CEP)
    if err != nil {
      slog.Error("error to get cep", "err", err, slog.String("package", "userservice"))
      return err
    }
    updateUser.Address = entity.UserAddress{
      CEP:        cep.CEP,
      IBGE:       cep.IBGE,
      UF:         cep.UF,
      City:       cep.Localidade,
      Complement: cep.Complemento,
      Street:     cep.Logradouro,
    }
  }
Enter fullscreen mode Exit fullscreen mode

Primeiro declaramos o updateUser, depois se tiver um e-mail dentro do if atribuímos o updateUser.Email = u.Email, depois ao atualizar se houver um cep, entramos no if e vamos chamar a nossa função GetCep e por fim inicializamos e atribuímos o updateUser.Address.

Depois atribuimos os valores finais:

  updateUser.ID = id
  updateUser.Name = u.Name
  updateUser.UpdatedAt = time.Now()
Enter fullscreen mode Exit fullscreen mode

Existe várias maneiras de fazer isso, essa é a forma que acho mais simples de entender o que está acontecendo e o que estamos atribuindo a nosso UserEntity.

Melhorando erros do handler

Podemos adicionar novos erros no nosso handler:

err = h.service.CreateUser(r.Context(), req)
  if err != nil {
    slog.Error(fmt.Sprintf("error to create user: %v", err), slog.String("package", "userhandler"))
    if err.Error() == "cep not found" {
      w.WriteHeader(http.StatusNotFound)
      msg := httperr.NewNotFoundError("cep not found")
      json.NewEncoder(w).Encode(msg)
      return
    }
    w.WriteHeader(http.StatusInternalServerError)
    msg := httperr.NewBadRequestError("error to create user")
    json.NewEncoder(w).Encode(msg)
    return
  }
Enter fullscreen mode Exit fullscreen mode

Adicionamos o erro "cep not found" para novos erro do cep, adicione apenas no CreateUser e UpdateUser.

Considerações finais

Nesse post conseguimos avançar bastante a nossa api, deixamos nosso service de usuário praticamente pronto, fizemos o cadastro de endereço chamando uma api de terceiros, deixamos o contrato entre service e repository pronto, aos poucos estamos deixando nossa api mais completa e robusta.

Agora temos uma newsletter, se inscreva e receba um aviso quando sair novos posts, se inscrever

Próximos passos

Na parte 5 vamos partir para a autenticação do usuário, protegendo as rotas da nossa aplicação, criando um service separado para lidar com a criação do token JWT, vamos adicionar um middleware personalizado para capturar log de cada chamada http pegando dados do usuário.

Link do repositório

repositório do projeto

link do projeto no meu blog

Gopher credits

. . . . . . . . . . . . . . . . . . . . . .