mirror of
https://github.com/3ybactuk/marketplace-go-service-project.git
synced 2025-10-30 22:13:44 +03:00
[hw-1] implement cart service
This commit is contained in:
118
cart/internal/domain/cart/repository/in_memory_repository.go
Normal file
118
cart/internal/domain/cart/repository/in_memory_repository.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"route256/cart/internal/domain/entity"
|
||||
"route256/cart/internal/domain/model"
|
||||
)
|
||||
|
||||
type storage = map[entity.UID]*entity.Cart
|
||||
|
||||
type InMemoryRepository struct {
|
||||
storage storage
|
||||
mx sync.RWMutex
|
||||
}
|
||||
|
||||
func NewInMemoryRepository(cap int) *InMemoryRepository {
|
||||
return &InMemoryRepository{
|
||||
storage: make(storage, cap),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *InMemoryRepository) AddItem(_ context.Context, userID entity.UID, item *model.Item) error {
|
||||
r.mx.Lock()
|
||||
defer r.mx.Unlock()
|
||||
|
||||
cart, ok := r.storage[userID]
|
||||
if !ok {
|
||||
cart = &entity.Cart{
|
||||
UserID: userID,
|
||||
Items: []entity.Sku{},
|
||||
ItemCount: map[entity.Sku]uint32{},
|
||||
}
|
||||
|
||||
r.storage[userID] = cart
|
||||
}
|
||||
|
||||
if _, ok := cart.ItemCount[item.Product.Sku]; !ok {
|
||||
cart.Items = append(cart.Items, item.Product.Sku)
|
||||
}
|
||||
|
||||
cart.ItemCount[item.Product.Sku] += item.Count
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *InMemoryRepository) GetItemsByUserID(_ context.Context, userID entity.UID) (entity.Cart, error) {
|
||||
r.mx.Lock()
|
||||
defer r.mx.Unlock()
|
||||
|
||||
cart, ok := r.storage[userID]
|
||||
if !ok {
|
||||
return entity.Cart{}, nil
|
||||
}
|
||||
|
||||
resultCart := entity.Cart{
|
||||
UserID: userID,
|
||||
Items: make([]entity.Sku, len(cart.Items)),
|
||||
ItemCount: make(map[entity.Sku]uint32, len(cart.ItemCount)),
|
||||
}
|
||||
|
||||
for i, sku := range cart.Items {
|
||||
resultCart.Items[i] = cart.Items[i]
|
||||
resultCart.ItemCount[sku] = cart.ItemCount[sku]
|
||||
}
|
||||
|
||||
return resultCart, nil
|
||||
}
|
||||
|
||||
func (r *InMemoryRepository) DeleteItem(_ context.Context, userID entity.UID, sku entity.Sku) error {
|
||||
r.mx.Lock()
|
||||
defer r.mx.Unlock()
|
||||
|
||||
cart, ok := r.storage[userID]
|
||||
if !ok {
|
||||
return model.ErrCartNotFound
|
||||
}
|
||||
|
||||
if len(cart.Items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
delete(cart.ItemCount, sku)
|
||||
|
||||
i := 0
|
||||
found := false
|
||||
for _, v := range cart.Items {
|
||||
if v == sku {
|
||||
found = true
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
i++
|
||||
}
|
||||
|
||||
if !found {
|
||||
return model.ErrItemNotFoundInCart
|
||||
}
|
||||
|
||||
cart.Items[i] = cart.Items[len(cart.Items)-1]
|
||||
cart.Items = cart.Items[:len(cart.Items)-1]
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *InMemoryRepository) DeleteItemsByUserID(_ context.Context, userID entity.UID) error {
|
||||
r.mx.Lock()
|
||||
defer r.mx.Unlock()
|
||||
|
||||
_, ok := r.storage[userID]
|
||||
if ok {
|
||||
delete(r.storage, userID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
165
cart/internal/domain/cart/service/service.go
Normal file
165
cart/internal/domain/cart/service/service.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"route256/cart/internal/domain/entity"
|
||||
"route256/cart/internal/domain/model"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
AddItem(ctx context.Context, userID entity.UID, item *model.Item) error
|
||||
GetItemsByUserID(ctx context.Context, userID entity.UID) (entity.Cart, error)
|
||||
DeleteItem(ctx context.Context, userID entity.UID, sku entity.Sku) error
|
||||
DeleteItemsByUserID(ctx context.Context, userID entity.UID) error
|
||||
}
|
||||
|
||||
type ProductService interface {
|
||||
GetProductBySku(ctx context.Context, sku entity.Sku) (*model.Product, error)
|
||||
}
|
||||
|
||||
type CartService struct {
|
||||
repository Repository
|
||||
productService ProductService
|
||||
}
|
||||
|
||||
func NewCartService(repository Repository, productService ProductService) *CartService {
|
||||
return &CartService{
|
||||
repository: repository,
|
||||
productService: productService,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CartService) AddItem(ctx context.Context, userID entity.UID, item *model.Item) error {
|
||||
if err := item.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid requested values: %w", err)
|
||||
}
|
||||
|
||||
if userID <= 0 {
|
||||
return fmt.Errorf("invalid userID")
|
||||
}
|
||||
|
||||
_, err := s.productService.GetProductBySku(ctx, item.Product.Sku)
|
||||
if err != nil {
|
||||
return fmt.Errorf("productService.GetProductBySku: %w", err)
|
||||
}
|
||||
|
||||
if err := s.repository.AddItem(ctx, userID, item); err != nil {
|
||||
return fmt.Errorf("repository.AddItemToCart: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserCart gets all user cart's item ids, gets the item description from the product-service
|
||||
// and return a list of the collected items.
|
||||
// In case of failed request to product-service, return nothing and error.
|
||||
//
|
||||
// TODO: add worker group, BUT it's OK for now,
|
||||
// assuming user does not have hundreds of different items in his cart.
|
||||
func (s *CartService) GetItemsByUserID(ctx context.Context, userID entity.UID) (*model.Cart, error) {
|
||||
if userID <= 0 {
|
||||
return nil, fmt.Errorf("userID invalid")
|
||||
}
|
||||
|
||||
cart, err := s.repository.GetItemsByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("repository.AddItemToCart: %w", err)
|
||||
}
|
||||
|
||||
if len(cart.Items) == 0 {
|
||||
return nil, model.ErrCartNotFound
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
resultCart := &model.Cart{
|
||||
UserID: userID,
|
||||
Items: make([]*model.Item, len(cart.Items)),
|
||||
TotalPrice: 0,
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for idx, sku := range cart.Items {
|
||||
wg.Add(1)
|
||||
|
||||
go func(sku entity.Sku, count uint32, idx int) {
|
||||
defer wg.Done()
|
||||
|
||||
product, err := s.productService.GetProductBySku(ctx, sku)
|
||||
if err != nil {
|
||||
select {
|
||||
case errCh <- fmt.Errorf("productService.GetProductBySku: %w", err):
|
||||
case <-ctx.Done():
|
||||
}
|
||||
|
||||
log.Error().Err(err).Msg("productService.GetProductBySku")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
resultCart.Items[idx] = &model.Item{
|
||||
Product: product,
|
||||
Count: count,
|
||||
}
|
||||
resultCart.TotalPrice += uint32(product.Price) * count
|
||||
}(sku, cart.ItemCount[sku], idx)
|
||||
}
|
||||
|
||||
doneCh := make(chan struct{})
|
||||
go func() {
|
||||
wg.Wait()
|
||||
|
||||
close(doneCh)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
cancel()
|
||||
|
||||
return nil, err
|
||||
case <-doneCh:
|
||||
slices.SortStableFunc(resultCart.Items, func(a, b *model.Item) int {
|
||||
return int(a.Product.Sku - b.Product.Sku)
|
||||
})
|
||||
|
||||
return resultCart, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CartService) DeleteItem(ctx context.Context, userID entity.UID, sku entity.Sku) error {
|
||||
if userID <= 0 {
|
||||
return fmt.Errorf("userID invalid")
|
||||
}
|
||||
|
||||
if sku <= 0 {
|
||||
return fmt.Errorf("sku invalid")
|
||||
}
|
||||
|
||||
if err := s.repository.DeleteItem(ctx, userID, sku); err != nil {
|
||||
return fmt.Errorf("repository.DeleteItemFromUserCart: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CartService) DeleteItemsByUserID(ctx context.Context, userID entity.UID) error {
|
||||
if userID <= 0 {
|
||||
return fmt.Errorf("userID invalid")
|
||||
}
|
||||
|
||||
if err := s.repository.DeleteItemsByUserID(ctx, userID); err != nil {
|
||||
return fmt.Errorf("repository.DeleteUserCart: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
12
cart/internal/domain/entity/cart.go
Normal file
12
cart/internal/domain/entity/cart.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package entity
|
||||
|
||||
type (
|
||||
UID uint64
|
||||
Sku int64
|
||||
)
|
||||
|
||||
type Cart struct {
|
||||
UserID UID
|
||||
Items []Sku
|
||||
ItemCount map[Sku]uint32
|
||||
}
|
||||
9
cart/internal/domain/model/cart.go
Normal file
9
cart/internal/domain/model/cart.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package model
|
||||
|
||||
import "route256/cart/internal/domain/entity"
|
||||
|
||||
type Cart struct {
|
||||
UserID entity.UID
|
||||
Items []*Item
|
||||
TotalPrice uint32
|
||||
}
|
||||
10
cart/internal/domain/model/errors.go
Normal file
10
cart/internal/domain/model/errors.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package model
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrProductNotFound = errors.New("invalid sku")
|
||||
|
||||
ErrCartNotFound = errors.New("cart not found")
|
||||
ErrItemNotFoundInCart = errors.New("item not found in cart")
|
||||
)
|
||||
16
cart/internal/domain/model/items.go
Normal file
16
cart/internal/domain/model/items.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package model
|
||||
|
||||
import "fmt"
|
||||
|
||||
type Item struct {
|
||||
Product *Product
|
||||
Count uint32 `validate:"gt=0"`
|
||||
}
|
||||
|
||||
func (i *Item) Validate() error {
|
||||
if err := validate.Struct(i); err != nil {
|
||||
return fmt.Errorf("invalid requested values: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
21
cart/internal/domain/model/product.go
Normal file
21
cart/internal/domain/model/product.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"route256/cart/internal/domain/entity"
|
||||
)
|
||||
|
||||
type Product struct {
|
||||
Name string
|
||||
Price int32
|
||||
Sku entity.Sku `validate:"gt=0"`
|
||||
}
|
||||
|
||||
func (p *Product) Validate() error {
|
||||
if err := validate.Struct(p); err != nil {
|
||||
return fmt.Errorf("invalid requested values: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
9
cart/internal/domain/model/validate.go
Normal file
9
cart/internal/domain/model/validate.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package model
|
||||
|
||||
import "github.com/go-playground/validator/v10"
|
||||
|
||||
var validate *validator.Validate
|
||||
|
||||
func init() {
|
||||
validate = validator.New()
|
||||
}
|
||||
75
cart/internal/domain/products/service/product_service.go
Normal file
75
cart/internal/domain/products/service/product_service.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"route256/cart/internal/domain/entity"
|
||||
"route256/cart/internal/domain/model"
|
||||
)
|
||||
|
||||
type ProductService struct {
|
||||
httpClient http.Client
|
||||
token string
|
||||
address string
|
||||
}
|
||||
|
||||
func NewProductService(httpClient http.Client, token string, address string) *ProductService {
|
||||
return &ProductService{
|
||||
httpClient: httpClient,
|
||||
token: token,
|
||||
address: address,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ProductService) GetProductBySku(ctx context.Context, sku entity.Sku) (*model.Product, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
fmt.Sprintf("http://%s/product/%d", s.address, sku),
|
||||
http.NoBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("http.NewRequestWithContext: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Add("X-API-KEY", s.token)
|
||||
|
||||
response, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("httpClient.Do: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return nil, model.ErrProductNotFound
|
||||
}
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("status not ok")
|
||||
}
|
||||
|
||||
resp := &GetProductResponse{}
|
||||
if err := json.NewDecoder(response.Body).Decode(resp); err != nil {
|
||||
return nil, fmt.Errorf("json.NewDecoder: %w", err)
|
||||
}
|
||||
|
||||
return &model.Product{
|
||||
Name: resp.Name,
|
||||
Price: resp.Price,
|
||||
Sku: entity.Sku(resp.Sku),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type GetProductResponse struct {
|
||||
Name string `json:"name"`
|
||||
Price int32 `json:"price"`
|
||||
Sku int64 `json:"sku"`
|
||||
}
|
||||
Reference in New Issue
Block a user