API completa em Golang - Parte 7

Wiliam V. Joaquim - Feb 3 - - Dev Community

O que vamos fazer?

Chegamos na última parte do nosso crud, na parte 7 vamos criar a funcionalidade de cadastro de produtos e categorias, vamos criar a funcionalidade de cadastrar produto e categoria, editar produto , listar todos os produtos e deletar produto.

Vamos trabalhar com relacionamentos many to many, uma categoria pode ter muitos produtos e um produto pode ter muitas categorias. Vamos fazer tudo neste último post, por isso deve ser um dos maiores da nossa série.

refatorando nosso handler

Primeiramente vamos fazer um ajuste no handler, estávamos separando o handler unicamente para o user, depois iriamos separar para product e category, porém dessa maneira gera uma complexidade maior ao passar a referência do servidor criado com go-chi, por isso resolvi refatorar e fazer um único handler.

Vamos mover o user_handler.go, auth_handler.go e user_interface_handler.go para a raiz da pasta handler, vamos também renomear o arquivo user_interface_handler.go para interface_handler.go, vamos ter apenas uma interface. Depois de mover você pode deletar a pasta userhandler, ficando assim:

handler

Vamos renomear as funções e interface do handler alterado interface_handler.go:

NewUserHandler para NewHandler

UserHandler para Handler

Precisamos alterar também o nome dos pacotes dos arquivos movidos de package userhandler para package handler.

Vamos ajustar nosso main.go:

  newHandler := handler.NewHandler(newUserService)

  // init routes
  router := chi.NewRouter()
  routes.InitRoutes(router, newHandler)
  routes.InitDocsRoutes(router)
Enter fullscreen mode Exit fullscreen mode

Criando as entities

Precisamos criar a entidade product e category, nosso produto e categoria terá relacionamento many-to-many, para criar o product é obrigatório passar pelo menos uma categoria.

Vamos criar um arquivo category_entity.go na pasta entity:

  type CategoryEntity struct {
    ID        string    `json:"id"`
    Title     string    `json:"title"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
  }
Enter fullscreen mode Exit fullscreen mode

Vamos criar um outro arquivo product_entity.go na pasta entity:

  type ProductEntity struct {
    ID          string    `json:"id"`
    Title       string    `json:"title"`
    Price       int32     `json:"price"`
    Categories  []string  `json:"categories"`
    Description string    `json:"description"`
    CreatedAt   time.Time `json:"created_at"`
    UpdatedAt   time.Time `json:"updated_at"`
  }

  type ProductCategoryEntity struct {
    ID         string    `json:"id"`
    ProductID  string    `json:"product_id"`
    CategoryID string    `json:"category_id"`
    CreatedAt  time.Time `json:"created_at"`
    UpdatedAt  time.Time `json:"updated_at"`
  }

  type ProductWithCategoryEntity struct {
    ID          string           `json:"id"`
    Title       string           `json:"title"`
    Price       int32            `json:"price"`
    Description string           `json:"description"`
    Categories  []CategoryEntity `json:"categories"`
    CreatedAt   time.Time        `json:"created_at"`
  }
Enter fullscreen mode Exit fullscreen mode

Teremos 3 entidades:

  • ProductEntity: vamos usar para criar o produto.

  • ProductCategoryEntity: vamos usar para criar o relacionamento entre produto e categoria.

  • ProductWithCategoryEntity: vamos usar para montar um retorno onde trazemos o produto e suas categorias.

Qual o motivo da entidade ProductCategoryEntity? Como nosso produto terá relacionamento many-to-many precisamos criar uma terceira tabela, ela será responsável por ligar uma categoria a um produto, vai ser nessa tabela conhecida como tabelas intermediária/junção/ligação pode ter várias nomenclaturas. Para ficar mais claro, veja a imagem abaixo:

many-to-many

Criando os handlers category e product

Vamos iniciar criando os métodos necessário no na interface handler que acabamos de alterar:

  func NewHandler(userService userservice.UserService,
    categoryService categoryservice.CategoryService,
    productservice productservice.ProductService) Handler {
    return &handler{
      userService:     userService,
      categoryService: categoryService,
      productservice:  productservice,
    }
  }

  type handler struct {
    userService     userservice.UserService
    categoryService categoryservice.CategoryService
    productservice  productservice.ProductService
  }
Enter fullscreen mode Exit fullscreen mode

Primeiro definimos que agora ao gerar uma nova instância do Handler vamos receber 3 services, user, product e category.

  type Handler interface {
    CreateUser(w http.ResponseWriter, r *http.Request)
    UpdateUser(w http.ResponseWriter, r *http.Request)
    GetUserByID(w http.ResponseWriter, r *http.Request)
    DeleteUser(w http.ResponseWriter, r *http.Request)
    FindManyUsers(w http.ResponseWriter, r *http.Request)
    UpdateUserPassword(w http.ResponseWriter, r *http.Request)
    Login(w http.ResponseWriter, r *http.Request)

    CreateCategory(w http.ResponseWriter, r *http.Request)

    CreateProduct(w http.ResponseWriter, r *http.Request)
    UpdateProduct(w http.ResponseWriter, r *http.Request)
    DeleteProduct(w http.ResponseWriter, r *http.Request)
    FindManyProducts(w http.ResponseWriter, r *http.Request)
  }
Enter fullscreen mode Exit fullscreen mode

Nossa interface ficou assim, com os métodos de usuário que já existiam porém agora adicionamos novos métodos. Para a categoria vamos fazer apenas de criação, vou deixar para vocês implementarem os demais, para produto vamos fazer o de criação, atualização, deletar e buscar vários, também vou deixar o método de buscar um único produto pelo id para vocês implementarem.

category handler

Crie um arquivo chamado category_handler, nas pasta handler esse endpoint vai ser bastante simples, vamos adicionar poucos dados a nossa categoria, vamos criar também nosso dto, crie um arquivo chamado category_dto.go na pasta dto.

  package dto

  type CreateCategoryDto struct {
    Title string `json:"title" validate:"required,min=3,max=30"`
  }
Enter fullscreen mode Exit fullscreen mode

Nossa categoria vai receber apenas um título, nada mais. Adicione outros dados se desejar.

  func (h *handler) CreateCategory(w http.ResponseWriter, r *http.Request) {
    var req dto.CreateCategoryDto

    if r.Body == http.NoBody {
      slog.Error("body is empty", slog.String("package", "categoryhandler"))
      w.WriteHeader(http.StatusBadRequest)
      msg := httperr.NewBadRequestError("body is required")
      json.NewEncoder(w).Encode(msg)
      return
    }
    err := json.NewDecoder(r.Body).Decode(&req)
    if err != nil {
      slog.Error("error to decode body", "err", err, slog.String("package", "categoryhandler"))
      w.WriteHeader(http.StatusBadRequest)
      msg := httperr.NewBadRequestError("error to decode body")
      json.NewEncoder(w).Encode(msg)
      return
    }
    httpErr := validation.ValidateHttpData(req)
    if httpErr != nil {
      slog.Error(fmt.Sprintf("error to validate data: %v", httpErr), slog.String("package", "categoryhandler"))
      w.WriteHeader(httpErr.Code)
      json.NewEncoder(w).Encode(httpErr)
      return
    }
    err = h.categoryService.CreateCategory(r.Context(), req)
    if err != nil {
      slog.Error(fmt.Sprintf("error to create category: %v", err), slog.String("package", "categoryhandler"))
      w.WriteHeader(http.StatusBadRequest)
    }
    w.WriteHeader(http.StatusCreated)
  }
Enter fullscreen mode Exit fullscreen mode

O handler vai ser basicamente igual ao que já fizemos aos usuários, a sua ide vai acusar o erro no categoryService.CreateCategory pois ainda não criamos o service, para o handler do category é apenas isso.

product handler

Crie um arquivo chamado product_handler, nas pasta handler, vamos criar também nosso dto, crie um arquivo chamado product_dto.go na pasta dto.

  package dto

  type CreateProductDto struct {
    Title       string   `json:"title" validate:"required,min=3,max=40"`
    Price       int32    `json:"price" validate:"required,min=1"`
    Categories  []string `json:"categories" validate:"required,min=1,dive,uuid4"`
    Description string   `json:"description" validate:"required,min=3,max=500"`
  }

  type UpdateProductDto struct {
    Title       string   `json:"title" validate:"omitempty,min=3,max=40"`
    Price       int32    `json:"price" validate:"omitempty,min=1"`
    Categories  []string `json:"categories" validate:"omitempty,min=1,dive,uuid4"`
    Description string   `json:"description" validate:"omitempty,min=3,max=500"`
  }

  type FindProductDto struct {
    Search     string   `json:"search" validate:"omitempty,min=2,max=40"`
    Categories []string `json:"categories" validate:"omitempty,min=1,dive,uuid4"`
  }
Enter fullscreen mode Exit fullscreen mode

Vamos ter 3 dtos para o product CreateProductDto, UpdateProductDto e para filtrar FindProductDto.

Validamos as categories com uso do dive para validar cada elemento do array, isso com o go playground validator ajuda muito a validação, as demais validações já utilizamos.

CreateProduct:

  func (h *handler) CreateProduct(w http.ResponseWriter, r *http.Request) {
    var req dto.CreateProductDto

    if r.Body == http.NoBody {
      slog.Error("body is empty", slog.String("package", "producthandler"))
      w.WriteHeader(http.StatusBadRequest)
      msg := httperr.NewBadRequestError("body is required")
      json.NewEncoder(w).Encode(msg)
      return
    }
    err := json.NewDecoder(r.Body).Decode(&req)
    if err != nil {
      slog.Error("error to decode body", "err", err, slog.String("package", "producthandler"))
      w.WriteHeader(http.StatusBadRequest)
      msg := httperr.NewBadRequestError("error to decode body")
      json.NewEncoder(w).Encode(msg)
      return
    }
    httpErr := validation.ValidateHttpData(req)
    if httpErr != nil {
      slog.Error(fmt.Sprintf("error to validate data: %v", httpErr), slog.String("package", "producthandler"))
      w.WriteHeader(httpErr.Code)
      json.NewEncoder(w).Encode(httpErr)
      return
    }

    err = h.productservice.CreateProduct(r.Context(), req)
    if err != nil {
      if err.Error() == "category not found" {
        w.WriteHeader(http.StatusNotFound)
        msg := httperr.NewNotFoundError("category not found")
        json.NewEncoder(w).Encode(msg)
        return
      }
      slog.Error(fmt.Sprintf("error to create category: %v", err), slog.String("package", "categoryhandler"))
      w.WriteHeader(http.StatusBadRequest)
    }
    w.WriteHeader(http.StatusCreated)
  }
Enter fullscreen mode Exit fullscreen mode

UpdateProduct:

  func (h *handler) UpdateProduct(w http.ResponseWriter, r *http.Request) {
    var req dto.UpdateProductDto

    productID := chi.URLParam(r, "id")
    if productID == "" {
      slog.Error("product id is required", slog.String("package", "producthandler"))
      w.WriteHeader(http.StatusBadRequest)
      msg := httperr.NewBadRequestError("product id is required")
      json.NewEncoder(w).Encode(msg)
      return
    }
    _, err := uuid.Parse(productID)
    if err != nil {
      slog.Error(fmt.Sprintf("error to parse product id: %v", err), slog.String("package", "producthandler"))
      w.WriteHeader(http.StatusBadRequest)
      msg := httperr.NewBadRequestError("invalid product id")
      json.NewEncoder(w).Encode(msg)
      return
    }
    if r.Body == http.NoBody {
      slog.Error("body is empty", slog.String("package", "producthandler"))
      w.WriteHeader(http.StatusBadRequest)
      msg := httperr.NewBadRequestError("body is required")
      json.NewEncoder(w).Encode(msg)
      return
    }
    err = json.NewDecoder(r.Body).Decode(&req)
    if err != nil {
      slog.Error("error to decode body", "err", err, slog.String("package", "producthandler"))
      w.WriteHeader(http.StatusBadRequest)
      msg := httperr.NewBadRequestError("error to decode body")
      json.NewEncoder(w).Encode(msg)
      return
    }
    httpErr := validation.ValidateHttpData(req)
    if httpErr != nil {
      slog.Error(fmt.Sprintf("error to validate data: %v", httpErr), slog.String("package", "producthandler"))
      w.WriteHeader(httpErr.Code)
      json.NewEncoder(w).Encode(httpErr)
      return
    }
    err = h.productservice.UpdateProduct(r.Context(), productID, req)
    if err != nil {
      if err.Error() == "product not found" {
        w.WriteHeader(http.StatusNotFound)
        msg := httperr.NewNotFoundError("product not found")
        json.NewEncoder(w).Encode(msg)
        return
      }
      if err.Error() == "category not found" {
        w.WriteHeader(http.StatusNotFound)
        msg := httperr.NewNotFoundError("category not found")
        json.NewEncoder(w).Encode(msg)
        return
      }
      slog.Error(fmt.Sprintf("error to update category: %v", err), slog.String("package", "categoryhandler"))
      w.WriteHeader(http.StatusBadRequest)
    }
    w.WriteHeader(http.StatusOK)
  }
Enter fullscreen mode Exit fullscreen mode

DeleteProduct:

  func (h *handler) DeleteProduct(w http.ResponseWriter, r *http.Request) {
    productID := chi.URLParam(r, "id")
    if productID == "" {
      slog.Error("product id is required", slog.String("package", "producthandler"))
      w.WriteHeader(http.StatusBadRequest)
      msg := httperr.NewBadRequestError("product id is required")
      json.NewEncoder(w).Encode(msg)
      return
    }
    _, err := uuid.Parse(productID)
    if err != nil {
      slog.Error(fmt.Sprintf("error to parse product id: %v", err), slog.String("package", "producthandler"))
      w.WriteHeader(http.StatusBadRequest)
      msg := httperr.NewBadRequestError("invalid product id")
      json.NewEncoder(w).Encode(msg)
      return
    }
    err = h.productservice.DeleteProduct(r.Context(), productID)
    if err != nil {
      if err.Error() == "product not found" {
        w.WriteHeader(http.StatusNotFound)
        msg := httperr.NewNotFoundError("product not found")
        json.NewEncoder(w).Encode(msg)
        return
      }
      slog.Error(fmt.Sprintf("error to delete category: %v", err), slog.String("package", "categoryhandler"))
      w.WriteHeader(http.StatusBadRequest)
    }
    w.WriteHeader(http.StatusOK)
  }
Enter fullscreen mode Exit fullscreen mode

FindManyProducts:

  func (h *handler) FindManyProducts(w http.ResponseWriter, r *http.Request) {
    var req dto.FindProductDto

    err := json.NewDecoder(r.Body).Decode(&req)
    if err != nil {
      slog.Error("error to decode body", "err", err, slog.String("package", "producthandler"))
      w.WriteHeader(http.StatusBadRequest)
      msg := httperr.NewBadRequestError("error to decode body")
      json.NewEncoder(w).Encode(msg)
      return
    }
    httpErr := validation.ValidateHttpData(req)
    if httpErr != nil {
      slog.Error(fmt.Sprintf("error to validate data: %v", httpErr), slog.String("package", "producthandler"))
      w.WriteHeader(httpErr.Code)
      json.NewEncoder(w).Encode(httpErr)
      return
    }
    products, err := h.productservice.FindManyProducts(r.Context(), req)
    if err != nil {
      slog.Error(fmt.Sprintf("error to find many products: %v", err), slog.String("package", "producthandler"))
      w.WriteHeader(http.StatusBadRequest)
    }
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(products)
  }
Enter fullscreen mode Exit fullscreen mode

Esses serão nossos métodos, tudo que já vimos nos posts anteriores, sem segredo.

Criando os services category e product

category service

Vamos criar um pasta dentro do service chamado categoryservice e um arquivo category_interface_service.go:

  func NewCategoryService(repo categoryrepository.CategoryRepository) CategoryService {
    return &service{
      repo,
    }
  }

  type service struct {
    repo categoryrepository.CategoryRepository
  }

  type CategoryService interface {
    CreateCategory(ctx context.Context, u dto.CreateCategoryDto) error
  }
Enter fullscreen mode Exit fullscreen mode

Assim igual ao handler o service será simples, agora vamos implementar, crie outro arquivo chamado category_service.go

  func (s *service) CreateCategory(ctx context.Context, u dto.CreateCategoryDto) error {
    categoryEntity := entity.CategoryEntity{
      ID:        uuid.New().String(),
      Title:     u.Title,
      CreatedAt: time.Now(),
      UpdatedAt: time.Now(),
    }
    err := s.repo.CreateCategory(ctx, &categoryEntity)
    if err != nil {
      return errors.New("error to create category")
    }
    return nil
  }
Enter fullscreen mode Exit fullscreen mode

Somente isso é suficiente para criar a nossa category.

product service

Vamos criar um pasta dentro do service chamado productservice e um arquivo product_interface_service.go:

  func NewProductService(repo productrepository.ProductRepository) ProductService {
    return &service{
      repo,
    }
  }

  type service struct {
    repo productrepository.ProductRepository
  }

  type ProductService interface {
    CreateProduct(ctx context.Context, u dto.CreateProductDto) error
    UpdateProduct(ctx context.Context, id string, u dto.UpdateProductDto) error
    DeleteProduct(ctx context.Context, id string) error
    FindManyProducts(ctx context.Context, d dto.FindProductDto) ([]response.ProductResponse, error)
  }
Enter fullscreen mode Exit fullscreen mode

Repare que temos um response.ProductResponse, precisamos criar também, crie na pasta response um arquivo chamado product_response.go e outro chamado category_response.go:

category_response.go:

  type CategoryResponse struct {
    ID    string `json:"id"`
    Title string `json:"title"`
  }
Enter fullscreen mode Exit fullscreen mode

Isso é o que vamos retornar da categoria na busca de produtos.

product_response.go:

  type ProductResponse struct {
    ID          string             `json:"id"`
    Title       string             `json:"title"`
    Price       int32              `json:"price"`
    Description string             `json:"description,omitempty"`
    Categories  []CategoryResponse `json:"categories"`
    CreatedAt   time.Time          `json:"created_at"`
  }
Enter fullscreen mode Exit fullscreen mode

Como um produto pode ter muitas categorias, vamos retornar um slice de CategoryResponse.

Vamos implementar nosso service, começando pelo CreateProduct:

  func (s *service) CreateProduct(ctx context.Context, u dto.CreateProductDto) error {
    productId := uuid.New().String()
    productEntity := entity.ProductEntity{
      ID:          productId,
      Title:       u.Title,
      Price:       u.Price,
      Categories:  u.Categories,
      Description: u.Description,
      CreatedAt:   time.Now(),
      UpdatedAt:   time.Now(),
    }
    var categories []entity.ProductCategoryEntity
    for _, categoryID := range u.Categories {
      exists, err := s.repo.GetCategoryByID(ctx, categoryID)
      if err != nil || !exists {
        slog.Error("category not found", slog.String("category_id", categoryID), slog.String("package", "productservice"))
        return errors.New("category not found")
      }
      categories = append(categories, entity.ProductCategoryEntity{
        ID:         uuid.New().String(),
        ProductID:  productId,
        CategoryID: categoryID,
        CreatedAt:  time.Now(),
        UpdatedAt:  time.Now(),
      })
    }
    err := s.repo.CreateProduct(ctx, &productEntity, categories)
    if err != nil {
      return err
    }
    return nil
  }
Enter fullscreen mode Exit fullscreen mode

No service vamos apenas criar o ProductEntity para repassar ao nosso repositório, depois fazemos um for para criar a entidade da categoria, como o produto pode ter várias categorias, é necessário.

Nessa parte você pode ver a vantagem de deixar a responsabilidade da criação do id do banco pelo service e não pelo banco, dessa forma não precisamos salvar o produto no banco para saber o id, dessa forma conseguimos salvar o produto e categoria todos de uma única vez, ainda vai dar erro no repositório CreateProduct e GetCategoryByID, ainda vamos implementar.

Nesse mesmo for já verificamos se a categoria existe.

UpdateProduct:

  func (s *service) UpdateProduct(ctx context.Context, id string, u dto.UpdateProductDto) error {
    exists, err := s.repo.GetProductByID(ctx, id)
    if err != nil || !exists {
      slog.Error("product not found", slog.String("product_id", id), slog.String("package", "productservice"))
      return errors.New("product not found")
    }
    // validate categories if they exist
    var categories []entity.ProductCategoryEntity
    if len(u.Categories) > 0 {
      for _, categoryID := range u.Categories {
        exists, err := s.repo.GetCategoryByID(ctx, categoryID)
        if err != nil || !exists {
          slog.Error("category not found", slog.String("category_id", categoryID), slog.String("package", "productservice"))
          return errors.New("category not found")
        }
      }
      // search for all categories of the product
      productCategories, err := s.repo.GetCategoriesByProductID(ctx, id)
      if err != nil {
        return errors.New("error getting categories by product id")
      }
      // remove all categories that are not in u.Categories
      for _, productCategory := range productCategories {
        found := false
        for _, categoryID := range u.Categories {
          if productCategory == categoryID {
            found = true
            break
          }
        }
        // if not found, then we can delete it
        if !found {
          err = s.repo.DeleteProductCategory(ctx, id, productCategory)
          if err != nil {
            return errors.New("error deleting product category")
          }
        }
      }

      for _, categoryID := range u.Categories {
        found := false
        for _, productCategory := range productCategories {
          if productCategory == categoryID {
            found = true
            break
          }
        }
        if !found {
          categories = append(categories, entity.ProductCategoryEntity{
            ID:         uuid.New().String(),
            ProductID:  id,
            CategoryID: categoryID,
            CreatedAt:  time.Now(),
            UpdatedAt:  time.Now(),
          })
        }
      }
    }
    productEntity := entity.ProductEntity{
      ID:          id,
      Title:       u.Title,
      Price:       u.Price,
      Description: u.Description,
      Categories:  u.Categories,
      UpdatedAt:   time.Now(),
    }
    err = s.repo.UpdateProduct(ctx, &productEntity, categories)
    if err != nil {
      return err
    }
    return nil
  }
Enter fullscreen mode Exit fullscreen mode

Esse deve é o método mais complexo, nessa função além de atualizar o produto, atualizamos também as categorias desse produto, mas existem algumas regras, ao atualizar o produto e o campo categories for informado no dto vamos verificar se todos os ids de categorias informadas já estão associadas ao produto, se não estiver, vamos criar se estiver não fazemos nada e caso tenha uma categoria associada a esse produto que não esteja no slice categories informada no dto, vamos deletar, existem diversas formas de implementar, poderíamos criar outros endpoits para remover uma categoria de um produto, associar uma categoria, mas em um único endpoint deixas um pouco mais simples de trabalhar.

  for _, categoryID := range u.Categories {
    exists, err := s.repo.GetCategoryByID(ctx, categoryID)
    if err != nil || !exists {
      slog.Error("category not found", slog.String("category_id", categoryID), slog.String("package", "productservice"))
      return errors.New("category not found")
    }
  }
Enter fullscreen mode Exit fullscreen mode

Primeiro verificamos se as categorias informadas no categories existem.

  productCategories, err := s.repo.GetCategoriesByProductID(ctx, id)
  if err != nil {
    return errors.New("error getting categories by product id")
  }
Enter fullscreen mode Exit fullscreen mode

Agora buscamos todas as categorias associadas a esse produto, vamos utilizar principalmente para remover as categorias associadas e não informadas no u.Categories

Por último colocamos os dados no slice productEntity para enviar ao repositório UpdateProduct.

DeleteProduct:

  func (s *service) DeleteProduct(ctx context.Context, id string) error {
    exists, err := s.repo.GetProductByID(ctx, id)
    if err != nil || !exists {
      slog.Error("product not found", slog.String("product_id", id), slog.String("package", "productservice"))
      return errors.New("product not found")
    }
    err = s.repo.DeleteProduct(ctx, id)
    if err != nil {
      return err
    }
    return nil
  }
Enter fullscreen mode Exit fullscreen mode

Esse é mais simples, apenas deletamos o produto e como vamos usar DELETE CASCADE os registros da tabela de ligação serão deletados junto.

FindManyProducts:

  func (s *service) FindManyProducts(ctx context.Context, d dto.FindProductDto) ([]response.ProductResponse, error) {
    products, err := s.repo.FindManyProducts(ctx, d)
    if err != nil {
      return nil, err
    }
    var productsResponse []response.ProductResponse
    for _, p := range products {
      var categories []response.CategoryResponse
      for _, c := range p.Categories {
        categories = append(categories, response.CategoryResponse{
          ID:    c.ID,
          Title: c.Title,
        })
      }
      productsResponse = append(productsResponse, response.ProductResponse{
        ID:          p.ID,
        Title:       p.Title,
        Description: p.Description,
        Price:       p.Price,
        Categories:  categories,
        CreatedAt:   p.CreatedAt,
      })
    }
    if len(productsResponse) == 0 {
      return []response.ProductResponse{}, nil
    }
    return productsResponse, nil
  }
Enter fullscreen mode Exit fullscreen mode

Por último vamos ter o FindManyProducts para listar os produtos, mas sem segredo, apenas mapeamos o que recebemos do repository, o repository vai retornar um slice da entidade ProductWithCategoryEntity que criamos anteriormente.

Com isso já temos o service e handler implementados.

Criando o repository

Primeiro vamos criar os arquivos, crie uma pasta dentro de repository chamado categoryrepository e productrepository e os arquivos
product_interface_repository.go, product_repository.go e category_interface_repository.go, category_repository.go.

category_interface_repository.go:

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

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

  type CategoryRepository interface {
    CreateCategory(ctx context.Context, c *entity.CategoryEntity) error
  }
Enter fullscreen mode Exit fullscreen mode

product_interface_repository.go:

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

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

  type ProductRepository interface {
    CreateProduct(ctx context.Context, p *entity.ProductEntity, c []entity.ProductCategoryEntity) error
    GetCategoryByID(ctx context.Context, id string) (bool, error)
    GetProductByID(ctx context.Context, id string) (bool, error)
    UpdateProduct(ctx context.Context, p *entity.ProductEntity, c []entity.ProductCategoryEntity) error
    GetCategoriesByProductID(ctx context.Context, id string) ([]string, error)
    DeleteProductCategory(ctx context.Context, productID, categoryID string) error
    DeleteProduct(ctx context.Context, id string) error
    FindManyProducts(ctx context.Context, d dto.FindProductDto) ([]entity.ProductWithCategoryEntity, error)
  }
Enter fullscreen mode Exit fullscreen mode

Com isso deixamos nossa interface pronta.

criando o sql

Vamos criar nosso sql, vamos começar pela migration, rode o comando:

  make create_migration
Enter fullscreen mode Exit fullscreen mode

Vamos ter uma nova migration vazia na pasta migrations

  CREATE TABLE category (
    id VARCHAR(36) NOT NULL PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP(3) NOT NULL
  );

  CREATE TABLE product (
    id VARCHAR(36) NOT NULL PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    price INTEGER NOT NULL,
    description TEXT NULL,
    created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP(3) NOT NULL
  );

  CREATE TABLE product_category (
    id VARCHAR(36) NOT NULL PRIMARY KEY,
    product_id VARCHAR(36) NOT NULL,
    category_id VARCHAR(36) NOT NULL,
    created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP(3) NOT NULL,
    FOREIGN KEY (product_id) REFERENCES product(id) ON DELETE CASCADE,
    FOREIGN KEY (category_id) REFERENCES category(id) ON DELETE CASCADE,
    UNIQUE (product_id, category_id)
  );
Enter fullscreen mode Exit fullscreen mode

Este vai ser nosso sql responsável por criar as tabelas que precisamos a tabela product_category é a tabela de ligação, onde podemos term inúmeros registros para um único produto.

  DROP TABLE IF EXISTS product_category;
  DROP TABLE IF EXISTS product;
  DROP TABLE IF EXISTS category;
Enter fullscreen mode Exit fullscreen mode

Esse sql acima é o que desfaz nossa migration.

Rodando as migrations com o comando:

  make migration_up
Enter fullscreen mode Exit fullscreen mode

Agora ao acessar o banco, as tabelas já constam no banco, não se esqueça de iniciar o container com

docker-compose up -d
Enter fullscreen mode Exit fullscreen mode

criando as consultas

Crie dois arquivos na pasta queries chamado categories.sql e products.sql:

categories.sql:

  -- name: CreateCategory :exec
  INSERT INTO category (id, title, created_at, updated_at)
  VALUES ($1, $2, $3, $4);
Enter fullscreen mode Exit fullscreen mode

Vamos ter apenas uma query de criação

products.sql:

  -- name: CreateProduct :exec
  INSERT INTO product (id, title, description, price, created_at, updated_at)
  VALUES ($1, $2, $3, $4, $5, $6);

  -- name: CreateProductCategory :exec
  INSERT INTO product_category (id, product_id, category_id, created_at, updated_at)
  VALUES ($1, $2, $3, $4, $5);

  -- name: GetCategoryByID :one
  SELECT EXISTS (SELECT 1 FROM category WHERE id = $1) AS category_exists;

  -- name: GetProductByID :one
  SELECT EXISTS (SELECT 1 FROM product WHERE id = $1) AS product_exists;

  -- name: UpdateProduct :exec
  UPDATE product
  SET
  title = COALESCE(sqlc.narg('title'), title),
  description = COALESCE(sqlc.narg('description'), description),
  price = COALESCE(sqlc.narg('price'), price),
  updated_at = $2
  WHERE id = $1;

  -- name: GetCategoriesByProductID :many
  SELECT pc.category_id FROM product_category pc WHERE pc.product_id = $1;

  -- name: DeleteProductCategory :exec
  DELETE FROM product_category WHERE product_id = $1 AND category_id = $2;

  -- name: DeleteProduct :exec
  DELETE FROM product WHERE id = $1;

  -- name: FindManyProducts :many
  SELECT
  p.id,
  p.title,
  p.description,
  p.price,
  p.created_at
  FROM product p
  JOIN product_category pc ON pc.product_id = p.id
  WHERE
    (pc.category_id  = ANY(@categories::TEXT[]) OR @categories::TEXT[] IS NULL)
    AND (
      p.title ILIKE '%' || COALESCE(@search, sqlc.narg('search'), '') || '%'
      OR
      p.description ILIKE '%' || COALESCE(@search, sqlc.narg('search'), '') || '%'
    )
  ORDER BY p.created_at DESC;

  -- name: GetProductCategories :many
  SELECT c.id, c.title FROM category c
  JOIN product_category pc ON pc.category_id = c.id
  WHERE pc.product_id = $1;
Enter fullscreen mode Exit fullscreen mode

para o product vamos ter mais queries, mas todas simples, a mais "elaborada" vai ser a de buscar produto GetProductCategories, mas tem um problema causado pelo sqlc nessa query.

A query GetProductCategories devolve vários produtos, e deveria retornar todas as categorias associadas ao produto de uma única vez, lembrando que o produto pode ter inúmeras categorias, para isso bastaria adicionar um JOIN na tabela category, porém o sqlc não consegue gerar a struct aninhada com o slice Category, como assim?

Se mudar a query para isso:

  -- name: FindManyProducts :many
  SELECT
  p.id,
  p.title,
  p.description,
  p.price,
  p.created_at
  c.* // retornando a category
  FROM product p
  JOIN product_category pc ON pc.product_id = p.id
  JOIN category c ON c.id = pc.category_id //adicionando o join
  WHERE
    (pc.category_id  = ANY(@categories::TEXT[]) OR @categories::TEXT[] IS NULL)
    AND (
      p.title ILIKE '%' || COALESCE(@search, sqlc.narg('search'), '') || '%'
      OR
      p.description ILIKE '%' || COALESCE(@search, sqlc.narg('search'), '') || '%'
    )
  ORDER BY p.created_at DESC;
Enter fullscreen mode Exit fullscreen mode

e rodar o comando sqlc generate, o sqlc vai gerar o código e fazer o scan, se acessar o arquivo gerado chamado products.sql.go na pasta sqlc vamos ter a struct de retorno da FindManyProducts:

  type FindManyProductsRow struct {
    ID          string
    Title       string
    Description sql.NullString
    Price       int32
    CreatedAt   time.Time
    ID_2        string
    Title_2     string
    CreatedAt_2 time.Time
    UpdatedAt   time.Time
  }
Enter fullscreen mode Exit fullscreen mode

Repare que o sqlc normaliza tudo na mesma struct, mas o que precisamos seria algo do tipo:

  type FindManyProductsRow struct {
    ID          string
    Title       string
    Description sql.NullString
    Price       int32
    CreatedAt   time.Time
    Categories []Categories
  }

  type FindManyProductsCategoriesRow struct {
    ID          string
    Title       string
  }
Enter fullscreen mode Exit fullscreen mode

Porém o sqlc não faz isso no momento, porém conseguimos aninhar isso usando o sqlc.embed, alterando a query ficaria assim:

  -- name: FindManyProducts :many
  SELECT
  p.id,
  p.title,
  p.description,
  p.price,
  p.created_at,
  sqlc.embed(c) // usando o sqlc embed
  FROM product p
  JOIN product_category pc ON pc.product_id = p.id
  JOIN category c ON c.id = pc.category_id
  WHERE
    (pc.category_id  = ANY(@categories::TEXT[]) OR @categories::TEXT[] IS NULL)
    AND (
      p.title ILIKE '%' || COALESCE(@search, sqlc.narg('search'), '') || '%'
      OR
      p.description ILIKE '%' || COALESCE(@search, sqlc.narg('search'), '') || '%'
    )
  ORDER BY p.created_at DESC;
Enter fullscreen mode Exit fullscreen mode

Agora no arquivo gerado pelo sqlc teremos:

  type FindManyProductsRow struct {
    ID          string
    Title       string
    Description sql.NullString
    Price       int32
    CreatedAt   time.Time
    Category    Category
  }
Enter fullscreen mode Exit fullscreen mode

o sqlc aninhou porém não é um slice, como precisamos, já existe diversas questões sobre isso no repositório do sqlc, caso queira ver uma das discussões acesse aqui.

A solução seria fazer a consulta sem usar o sqlc, porém ficaria extenso demais abordar isso, a solução mais simples para não se prolongar é buscar os produtos, depois fazer um for e buscar as categorias de cada produto, por isso usamos a query GetProductCategories.

Ainda sobre a query FindManyProducts, fiz uma busca bem simples por categorias e texto, buscando por title ou description, é umas busca onde podemos passar um slice de categories, e vai retornar se o produto tiver alguma das categories.

Já a busca usando o search fazemos a busca muito simples, porém se tivermos um produto com o título Avião e no search buscar por aviao não vai retornar resultados, por conta do caracter especial ~. Existem várias soluções para isso, uma delas seria criar um novo campos chamado search por exemplo, esse campos seria salvo os palavras normalizadas, digamos que o títulos seja Avião Voador, no campos search ficaria Avião Voador,aviao voador, assim conseguimos buscar com e sem caracter especial.

Este é apenas uma das soluções, porém gera um certo trabalho manter esse campo sempre atualizado. Podemos utilizar também uma extensão do Postgres chamado unaccent, com essa extensão não precisamos normalizar os dados, isso é feito em tempo de pesquisa. Mas não vamos abordar no momento, talvez em um outro post.

implementando o repository

category repository

CreateCategory:

  func (r *repository) CreateCategory(ctx context.Context, c *entity.CategoryEntity) error {
    err := r.queries.CreateCategory(ctx, sqlc.CreateCategoryParams{
      ID:        c.ID,
      Title:     c.Title,
      CreatedAt: c.CreatedAt,
      UpdatedAt: c.UpdatedAt,
    })
    if err != nil {
      return err
    }
    return nil
  }
Enter fullscreen mode Exit fullscreen mode

Apenas salvamos a nova categoria.

product repository

CreateProduct:

  func (r *repository) CreateProduct(ctx context.Context, p *entity.ProductEntity, c []entity.ProductCategoryEntity) error {
    err := transaction.Run(ctx, r.db, func(q *sqlc.Queries) error {
      var err error
      err = q.CreateProduct(ctx, sqlc.CreateProductParams{
        ID:          p.ID,
        Title:       p.Title,
        Price:       p.Price,
        Description: sql.NullString{String: p.Description, Valid: p.Description != ""},
        CreatedAt:   p.CreatedAt,
        UpdatedAt:   p.UpdatedAt,
      })
      if err != nil {
        return err
      }
      for _, category := range c {
        err = q.CreateProductCategory(ctx, sqlc.CreateProductCategoryParams{
          ID:         category.ID,
          ProductID:  p.ID,
          CategoryID: category.CategoryID,
          CreatedAt:  category.CreatedAt,
          UpdatedAt:  category.UpdatedAt,
        })
        if err != nil {
          return err
        }
      }
      return nil
    })
    if err != nil {
      slog.Error("error to create product, roll back applied", "err", err)
      return err
    }
    return nil
  }
Enter fullscreen mode Exit fullscreen mode

Na criação de produtos usamos as transactions, primeiro salvamos o produto e depois fazemos um for para salvar cada CreateProductCategory.

GetCategoryByID:

  func (r *repository) GetCategoryByID(ctx context.Context, id string) (bool, error) {
    exists, err := r.queries.GetCategoryByID(ctx, id)
    if err != nil || err == sql.ErrNoRows {
      return false, err
    }
    return exists, nil
  }
Enter fullscreen mode Exit fullscreen mode

Usamos para buscar uma categoria pelo id, apenas para validar se existe.

GetProductByID:

  func (r *repository) GetProductByID(ctx context.Context, id string) (bool, error) {
    exists, err := r.queries.GetProductByID(ctx, id)
    if err != nil || err == sql.ErrNoRows {
      return false, err
    }
    return exists, nil
  }
Enter fullscreen mode Exit fullscreen mode

Buscamos um produto pelo id, apenas para validar se existe.

UpdateProduct:

  func (r *repository) UpdateProduct(ctx context.Context, p *entity.ProductEntity, c []entity.ProductCategoryEntity) error {
    err := transaction.Run(ctx, r.db, func(q *sqlc.Queries) error {
      var err error
      err = q.UpdateProduct(ctx, sqlc.UpdateProductParams{
        ID:          p.ID,
        Title:       sql.NullString{String: p.Title, Valid: p.Title != ""},
        Price:       sql.NullInt32{Int32: p.Price, Valid: p.Price != 0},
        Description: sql.NullString{String: p.Description, Valid: p.Description != ""},
        UpdatedAt:   p.UpdatedAt,
      })
      if err != nil {
        return err
      }
      for _, category := range c {
        err = q.CreateProductCategory(ctx, sqlc.CreateProductCategoryParams{
          ID:         category.ID,
          ProductID:  p.ID,
          CategoryID: category.CategoryID,
          CreatedAt:  category.CreatedAt,
          UpdatedAt:  category.UpdatedAt,
        })
        if err != nil {
          return err
        }
      }
      return nil
    })
    if err != nil {
      slog.Error("error to update product, roll back applied", "err", err)
      return err
    }
    return nil
  }
Enter fullscreen mode Exit fullscreen mode

Para atualizar o produto é semelhante a criação, utilizamos transaction e utilizamos o sql.NullString e sql.NullInt32 para tratar valores possivelmente nulos.

GetCategoriesByProductID:

  func (r *repository) GetCategoriesByProductID(ctx context.Context, id string) ([]string, error) {
    categories, err := r.queries.GetCategoriesByProductID(ctx, id)
    if err != nil {
      return nil, err
    }
    return categories, nil
  }
Enter fullscreen mode Exit fullscreen mode

Retornamos os ids das categorias de um produto.

DeleteProductCategory:

  func (r *repository) DeleteProductCategory(ctx context.Context, productID, categoryID string) error {
    err := r.queries.DeleteProductCategory(ctx, sqlc.DeleteProductCategoryParams{
      ProductID:  productID,
      CategoryID: categoryID,
    })
    if err != nil {
      return err
    }
    return nil
  }
Enter fullscreen mode Exit fullscreen mode

Usamos para deletar um registro da tabela de ligação.

DeleteProduct:

  func (r *repository) DeleteProduct(ctx context.Context, id string) error {
    err := r.queries.DeleteProduct(ctx, id)
    if err != nil {
      return err
    }
    return nil
  }
Enter fullscreen mode Exit fullscreen mode

Deleta o nosso produto pelo id.

FindManyProducts:

  func (r *repository) FindManyProducts(ctx context.Context, d dto.FindProductDto) ([]entity.ProductWithCategoryEntity, error) {
    products, err := r.queries.FindManyProducts(ctx, sqlc.FindManyProductsParams{
      Categories: d.Categories,
      Search:     sql.NullString{String: d.Search, Valid: d.Search != ""},
    })
    if err != nil {
      return nil, err
    }
    var response []entity.ProductWithCategoryEntity
    for _, p := range products {
      var category []entity.CategoryEntity
      categories, err := r.queries.GetProductCategories(ctx, p.ID)
      if err != nil {
        return nil, err
      }
      for _, c := range categories {
        category = append(category, entity.CategoryEntity{
          ID:    c.ID,
          Title: c.Title,
        })
      }
      response = append(response, entity.ProductWithCategoryEntity{
        ID:          p.ID,
        Title:       p.Title,
        Description: p.Description.String,
        Price:       p.Price,
        Categories:  category,
        CreatedAt:   p.CreatedAt,
      })
    }
    return response, nil
  }
Enter fullscreen mode Exit fullscreen mode

Por último, buscamos o produto, aqui tem o que mencionei sobre a busca de categorias

  for _, c := range categories {
      category = append(category, entity.CategoryEntity{
      ID:    c.ID,
      Title: c.Title,
    })
  }
Enter fullscreen mode Exit fullscreen mode

Nesse for buscamos as categorias de cada produto do índice do primeiro for.

Ajustando a main

Tudo pronto! mas para rodar, precisamos instanciar nossos services no main.go e passar para o handler:

 // user
  userRepo := userrepository.NewUserRepository(dbConnection, queries)
  newUserService := userservice.NewUserService(userRepo)

  // category
  categoryRepo := categoryrepository.NewCategoryRepository(dbConnection, queries)
  newCategoryService := categoryservice.NewCategoryService(categoryRepo)

  // product
  productRepo := productrepository.NewProductRepository(dbConnection, queries)
  productsService := productservice.NewProductService(productRepo)

  newHandler := handler.NewHandler(newUserService, newCategoryService, productsService)

  // init routes
  router := chi.NewRouter()
  routes.InitRoutes(router, newHandler)
  routes.InitDocsRoutes(router)
Enter fullscreen mode Exit fullscreen mode

Agora podemos rodas nossa aplicação e testar com go run cmd/webserver/main.go:

adicionando as chamadas no gttp_client.http:

### Products

## CreateProduct
POST http://localhost:8080/product HTTP/1.1
content-type: application/json
Authorization: Bearer {{token}}

{
  "title": "Samsung",
  "description": "Celular bacana",
  "categories": ["category_id"],
  "price": 39900
}

###

## UpdateProduct
PATCH http://localhost:8080/product/37545729-e891-40b5-946c-8e7d55bd686b HTTP/1.1
content-type: application/json
Authorization: Bearer {{token}}

{
  "categories": ["07145e70-2a8e-4f71-9165-f0d450afa524"]
}

###

## DeleteProduct
DELETE http://localhost:8080/product/f720e1ce-cb88-4f72-a765-0250c1a525e3 HTTP/1.1
content-type: application/json
Authorization: Bearer {{token}}

###

## FindManyProducts
GET http://localhost:8080/product HTTP/1.1
content-type: application/json
Authorization: Bearer {{token}}

{
  "categories": ["category_id"]
}
Enter fullscreen mode Exit fullscreen mode

Agora é só brincar fazer as chamadas e criar produtos e categorias.

gif

Documentando

Para finalizar, precisamos apenas documentar nossos novos endpoints.

category_handler.go:

  // Create category
  //    @Summary        Create new category
  //    @Description    Endpoint for create category
  //    @Tags           category
  //    @Accept         json
  //    @Produce        json
  //    @Param          body    body    dto.CreateCategoryDto   true    "Create category dto"   true
  //    @Success        200
  //    @Failure        400 {object}    httperr.RestErr
  //    @Failure        500 {object}    httperr.RestErr
  //    @Router         /category [post]
  func (h *handler) CreateCategory(w http.ResponseWriter, r *http.Request) {}
Enter fullscreen mode Exit fullscreen mode

product_handler.go:

// Create product
//  @Summary        Create new product
//  @Description    Endpoint for create product
//  @Tags           product
//  @Accept         json
//  @Produce        json
//  @Param          body    body    dto.CreateProductDto    true    "Create product dto"    true
//  @Success        200
//  @Failure        400 {object}    httperr.RestErr
//  @Failure        500 {object}    httperr.RestErr
//  @Router         /product [post]
func (h *handler) CreateProduct(w http.ResponseWriter, r *http.Request) {}
Enter fullscreen mode Exit fullscreen mode
// Update product
//  @Summary        Update product
//  @Description    Endpoint for update product
//  @Tags           product
//  @Accept         json
//  @Produce        json
//  @Param          body    body    dto.UpdateProductDto    true    "Update product dto"    true
//  @Param          id      path    string                  true    "product id"
//  @Success        200
//  @Failure        400 {object}    httperr.RestErr
//  @Failure        500 {object}    httperr.RestErr
//  @Router         /productt/{id} [patch]
func (h *handler) UpdateProduct(w http.ResponseWriter, r *http.Request) {}
Enter fullscreen mode Exit fullscreen mode
// Delete product
//  @Summary        Delete product
//  @Description    Endpoint for update product
//  @Tags           product
//  @Accept         json
//  @Produce        json
//  @Param          id  path    string  true    "product id"
//  @Success        200
//  @Failure        400 {object}    httperr.RestErr
//  @Failure        500 {object}    httperr.RestErr
//  @Router         /product/{id} [delete]
func (h *handler) DeleteProduct(w http.ResponseWriter, r *http.Request)
Enter fullscreen mode Exit fullscreen mode
//  Search products
//  @Summary        Search products
//  @Description    Endpoint for search product
//  @Tags           product
//  @Accept         json
//  @Produce        json
//  @Param          body    body        dto.FindProductDto  true    "Search products"   true
//  @Success        200     {object}    response.ProductResponse
//  @Failure        400     {object}    httperr.RestErr
//  @Failure        500     {object}    httperr.RestErr
//  @Router         /product [get]
func (h *handler) FindManyProducts(w http.ResponseWriter, r *http.Request) {}
Enter fullscreen mode Exit fullscreen mode

Vamos rodar dois comandos do swag, o primeiro para formatar:

  swag fmt
Enter fullscreen mode Exit fullscreen mode

O segundo para gerar a documentação no padrão open api.

  swag init -g internal/handler/routes/docs_route.go
Enter fullscreen mode Exit fullscreen mode

Show! Agora rodando o projeto e acessando a url http://localhost:8080/docs/index.html#/ vamos ter nossa documentação pronta:

docs

Considerações finais

Nesse posta vimos mais sobre como usar sqlc, transactions, e praticamos um pouco mais do que abordamos nos posts anteriores.

É isso galera, este é o último post da nossa série. Mas você deve estar se perguntando e os testes? Bom, não pretendo abordar os teste nesse série, para não ficar algo imenso. Mas seguindo a pirâmide de teste, o teste unitário não agregaria muito valor a nossa api, uma vez que boa parte da nossa api é pegar dado e salvar no banco, com teste unitário teríamos que criar mocks do banco, não estaríamos testado nada de fato, mas claro que os teste unitários também são importantes, mas no nosso cenário não iria gerar muito valor ao nosso código.

Por isso pretendo trazer outro post separado fazendo teste de integração da nossa api utilizando test containers, esse tipo de teste vai agregar valor a nossa api, fazendo testes onde não vamos utilizar mocks.

Se inscreva na newsletter para ficar por dentro das atualizações.

Espero que vocês tenham curtido a série.

Link do repositório

repositório do projeto

link do projeto no meu blog

Se inscreva e receba um aviso sobre novos posts, participar

Gopher credits

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