API completa em Golang - Parte 4
O que vamos fazer?
Na parte 4 do nosso crud, vamos fazer a lógica do nosso service de usuário.
Se ainda não viu os posts anteriores leia eles primeiro.
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 geradao 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"`
}
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
}
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
}
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")
}
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")
}
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(),
}
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
}
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")
}
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")
}
}
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
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")
}
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
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
Apenas verificamos se o usuário existe e depois chamamos o repository.
Listar todos os usuários
Esse metódo 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
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")
}
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")
}
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")
}
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
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
}
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"`
}
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
}
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"
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"`
}
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"`
}
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(),
}
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,
}
}
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 atribuímos os valores finais:
updateUser.ID = id
updateUser.Name = u.Name
updateUser.UpdatedAt = time.Now()
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
}
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