mirror of
https://github.com/3ybactuk/marketplace-go-service-project.git
synced 2025-10-30 05:53:45 +03:00
[hw-1] implement cart service
This commit is contained in:
4
Makefile
4
Makefile
@@ -4,3 +4,7 @@ include make/build.mk
|
|||||||
lint: cart-lint loms-lint notifier-lint comments-lint
|
lint: cart-lint loms-lint notifier-lint comments-lint
|
||||||
|
|
||||||
build: cart-build loms-build notifier-build comments-build
|
build: cart-build loms-build notifier-build comments-build
|
||||||
|
|
||||||
|
run-all:
|
||||||
|
echo "starting build"
|
||||||
|
docker-compose up --build
|
||||||
|
|||||||
20
cart/Dockerfile
Normal file
20
cart/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FROM golang:1.23.1-alpine as builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY go.mod go.mod
|
||||||
|
COPY go.sum go.sum
|
||||||
|
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./cmd/server/main.go
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
COPY --from=builder server /bin/server
|
||||||
|
COPY configs/values_local.yaml /bin/config/values_local.yaml
|
||||||
|
|
||||||
|
ENV CONFIG_FILE=/bin/config/values_local.yaml
|
||||||
|
|
||||||
|
ENTRYPOINT ["/bin/server"]
|
||||||
18
cart/cmd/server/main.go
Normal file
18
cart/cmd/server/main.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"route256/cart/internal/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
srv, err := app.NewApp(os.Getenv("CONFIG_FILE"))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := srv.ListenAndServe(); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
service:
|
service:
|
||||||
host: localhost
|
|
||||||
port: 8080
|
port: 8080
|
||||||
|
log_level: trace
|
||||||
workers: 5
|
workers: 5
|
||||||
|
|
||||||
jaeger:
|
jaeger:
|
||||||
@@ -8,7 +8,7 @@ jaeger:
|
|||||||
port: 6831
|
port: 6831
|
||||||
|
|
||||||
product_service:
|
product_service:
|
||||||
host: localhost
|
host: product-service
|
||||||
port: 8082
|
port: 8082
|
||||||
token: testToken
|
token: testToken
|
||||||
limit: 10
|
limit: 10
|
||||||
|
|||||||
22
cart/go.mod
22
cart/go.mod
@@ -1,3 +1,25 @@
|
|||||||
module route256/cart
|
module route256/cart
|
||||||
|
|
||||||
go 1.23.1
|
go 1.23.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/rs/zerolog v1.34.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
golang.org/x/crypto v0.33.0 // indirect
|
||||||
|
golang.org/x/net v0.34.0 // indirect
|
||||||
|
golang.org/x/text v0.22.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-playground/validator/v10 v10.26.0
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
37
cart/go.sum
Normal file
37
cart/go.sum
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
||||||
|
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||||
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||||
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
|
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
|
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||||
|
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||||
|
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||||
|
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
104
cart/internal/app/app.go
Normal file
104
cart/internal/app/app.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"route256/cart/internal/app/server"
|
||||||
|
"route256/cart/internal/domain/cart/repository"
|
||||||
|
"route256/cart/internal/domain/cart/service"
|
||||||
|
product_service "route256/cart/internal/domain/products/service"
|
||||||
|
"route256/cart/internal/infra/config"
|
||||||
|
"route256/cart/internal/infra/http/middlewares"
|
||||||
|
"route256/cart/internal/infra/http/round_trippers"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
productsRetryAttemptsDefault = 3
|
||||||
|
productsInitialDelaySecDefault = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
config *config.Config
|
||||||
|
server http.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApp(configPath string) (*App, error) {
|
||||||
|
c, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
|
||||||
|
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
||||||
|
|
||||||
|
if c.Service.LogLevel != "" {
|
||||||
|
level, err := zerolog.ParseLevel(c.Service.LogLevel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unknown log level `%s` provided: %w", c.Service.LogLevel, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
zerolog.SetGlobalLevel(level)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.WithLevel(zerolog.GlobalLevel()).Msgf("using logging level=`%s`", zerolog.GlobalLevel().String())
|
||||||
|
|
||||||
|
app := &App{
|
||||||
|
config: c,
|
||||||
|
}
|
||||||
|
app.server.Handler = app.bootstrapHandlers()
|
||||||
|
|
||||||
|
return app, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) ListenAndServe() error {
|
||||||
|
address := fmt.Sprintf("%s:%s", app.config.Service.Host, app.config.Service.Port)
|
||||||
|
|
||||||
|
l, err := net.Listen("tcp", address)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Msgf("Serving cart at http://%s", l.Addr())
|
||||||
|
|
||||||
|
return app.server.Serve(l)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) bootstrapHandlers() http.Handler {
|
||||||
|
transport := http.DefaultTransport
|
||||||
|
transport = round_trippers.NewLogRoundTripper(transport)
|
||||||
|
transport = round_trippers.NewRetryRoundTripper(transport, productsRetryAttemptsDefault, productsInitialDelaySecDefault)
|
||||||
|
|
||||||
|
httpClient := http.Client{
|
||||||
|
Transport: transport,
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
productService := product_service.NewProductService(
|
||||||
|
httpClient,
|
||||||
|
app.config.ProductService.Token,
|
||||||
|
fmt.Sprintf("%s:%s", app.config.ProductService.Host, app.config.ProductService.Port),
|
||||||
|
)
|
||||||
|
|
||||||
|
const userCartCap = 100
|
||||||
|
cartRepository := repository.NewInMemoryRepository(userCartCap)
|
||||||
|
cartService := service.NewCartService(cartRepository, productService)
|
||||||
|
|
||||||
|
s := server.NewServer(cartService)
|
||||||
|
|
||||||
|
mx := http.NewServeMux()
|
||||||
|
mx.HandleFunc("POST /user/{user_id}/cart/{sku_id}", s.AddItemHandler)
|
||||||
|
mx.HandleFunc("GET /user/{user_id}/cart", s.GetItemsByUserIDHandler)
|
||||||
|
mx.HandleFunc("DELETE /user/{user_id}/cart/{sku_id}", s.DeleteItemHandler)
|
||||||
|
mx.HandleFunc("DELETE /user/{user_id}/cart", s.DeleteItemsByUserIDHandler)
|
||||||
|
|
||||||
|
h := middlewares.NewTimerMiddleware(mx)
|
||||||
|
|
||||||
|
return h
|
||||||
|
}
|
||||||
96
cart/internal/app/server/add_item_handler.go
Normal file
96
cart/internal/app/server/add_item_handler.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"route256/cart/internal/domain/entity"
|
||||||
|
"route256/cart/internal/domain/model"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CreateReviewRequest struct {
|
||||||
|
Count uint32 `json:"count" validate:"required,gt=0"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) AddItemHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request CreateReviewRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
makeErrorResponse(w, err, http.StatusBadRequest)
|
||||||
|
|
||||||
|
log.Trace().Err(err).Msg("failed decoding request")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
if request.Count <= 0 {
|
||||||
|
err := fmt.Errorf("count must be greater than 0")
|
||||||
|
|
||||||
|
makeErrorResponse(w, err, http.StatusBadRequest)
|
||||||
|
|
||||||
|
log.Trace().Err(err).Msg("body validation failed")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
strUserID := r.PathValue("user_id")
|
||||||
|
userID, err := strconv.ParseInt(strUserID, 10, 64)
|
||||||
|
if err != nil || userID <= 0 {
|
||||||
|
if err == nil {
|
||||||
|
err = fmt.Errorf("user_id must be greater than 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
makeErrorResponse(w, err, http.StatusBadRequest)
|
||||||
|
|
||||||
|
log.Trace().Err(err).Msgf("user_id=`%s`", strUserID)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
strSku := r.PathValue("sku_id")
|
||||||
|
sku, err := strconv.ParseInt(strSku, 10, 64)
|
||||||
|
if err != nil || sku <= 0 {
|
||||||
|
if err == nil {
|
||||||
|
err = fmt.Errorf("sku_id must be greater than 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
makeErrorResponse(w, err, http.StatusBadRequest)
|
||||||
|
|
||||||
|
log.Trace().Err(err).Msgf("sku_id=`%s`", strUserID)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
item := &model.Item{
|
||||||
|
Product: &model.Product{
|
||||||
|
Name: "",
|
||||||
|
Price: 0,
|
||||||
|
Sku: entity.Sku(sku),
|
||||||
|
},
|
||||||
|
Count: request.Count,
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := entity.UID(userID)
|
||||||
|
|
||||||
|
if err := s.cartService.AddItem(r.Context(), uid, item); err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, model.ErrProductNotFound):
|
||||||
|
makeErrorResponse(w, err, http.StatusPreconditionFailed)
|
||||||
|
|
||||||
|
log.Trace().Err(err).Msgf("product does not exist")
|
||||||
|
default:
|
||||||
|
makeErrorResponse(w, err, http.StatusInternalServerError)
|
||||||
|
|
||||||
|
log.Trace().Err(err).Msgf("unknown error")
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
37
cart/internal/app/server/delete_cart_handler.go
Normal file
37
cart/internal/app/server/delete_cart_handler.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"route256/cart/internal/domain/entity"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) DeleteItemsByUserIDHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
strUserID := r.PathValue("user_id")
|
||||||
|
userID, err := strconv.ParseInt(strUserID, 10, 64)
|
||||||
|
if err != nil || userID <= 0 {
|
||||||
|
if err == nil {
|
||||||
|
err = fmt.Errorf("user_id must be greater than 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
makeErrorResponse(w, err, http.StatusBadRequest)
|
||||||
|
|
||||||
|
log.Trace().Err(err).Msgf("user_id=`%s`", strUserID)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.cartService.DeleteItemsByUserID(r.Context(), entity.UID(userID)); err != nil {
|
||||||
|
makeErrorResponse(w, err, http.StatusBadRequest)
|
||||||
|
|
||||||
|
log.Trace().Err(err).Msgf("cartService.DeleteItemFromCart failed for uid=%d: %d", userID, http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
55
cart/internal/app/server/delete_item_handler.go
Normal file
55
cart/internal/app/server/delete_item_handler.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"route256/cart/internal/domain/entity"
|
||||||
|
"route256/cart/internal/domain/model"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) DeleteItemHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
strUserID := r.PathValue("user_id")
|
||||||
|
userID, err := strconv.ParseInt(strUserID, 10, 64)
|
||||||
|
if err != nil || userID <= 0 {
|
||||||
|
if err == nil {
|
||||||
|
err = fmt.Errorf("user_id must be greater than 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
makeErrorResponse(w, err, http.StatusBadRequest)
|
||||||
|
|
||||||
|
log.Trace().Err(err).Msgf("user_id=`%s`", strUserID)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
strSku := r.PathValue("sku_id")
|
||||||
|
sku, err := strconv.ParseInt(strSku, 10, 64)
|
||||||
|
if err != nil || sku <= 0 {
|
||||||
|
if err == nil {
|
||||||
|
err = fmt.Errorf("sku_id must be greater than 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
makeErrorResponse(w, err, http.StatusBadRequest)
|
||||||
|
|
||||||
|
log.Trace().Err(err).Msgf("sku_id=`%s`", strUserID)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.cartService.DeleteItem(r.Context(), entity.UID(userID), entity.Sku(sku))
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, model.ErrCartNotFound), errors.Is(err, model.ErrItemNotFoundInCart), err == nil:
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
default:
|
||||||
|
makeErrorResponse(w, err, http.StatusBadRequest)
|
||||||
|
|
||||||
|
log.Trace().Err(err).Msgf("cartService.DeleteItemFromCart failed for uid=%d sku=%d: %d", userID, sku, http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
24
cart/internal/app/server/error_response.go
Normal file
24
cart/internal/app/server/error_response.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func makeErrorResponse(w http.ResponseWriter, err error, statusCode int) {
|
||||||
|
type ErrorMessage struct {
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(statusCode)
|
||||||
|
|
||||||
|
errResponse := &ErrorMessage{Message: err.Error()}
|
||||||
|
if errE := json.NewEncoder(w).Encode(errResponse); errE != nil {
|
||||||
|
log.Err(errE).Send()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
70
cart/internal/app/server/get_cart_handler.go
Normal file
70
cart/internal/app/server/get_cart_handler.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"route256/cart/internal/domain/entity"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CartItem struct {
|
||||||
|
Sku int64 `json:"sku"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Count uint32 `json:"count"`
|
||||||
|
Price uint32 `json:"price"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetUserCartResponse struct {
|
||||||
|
Items []CartItem `json:"items"`
|
||||||
|
TotalPrice uint32 `json:"total_price"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) GetItemsByUserIDHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
strUserID := r.PathValue("user_id")
|
||||||
|
userID, err := strconv.ParseInt(strUserID, 10, 64)
|
||||||
|
if err != nil || userID <= 0 {
|
||||||
|
if err == nil {
|
||||||
|
err = fmt.Errorf("user_id must be greater than 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
makeErrorResponse(w, err, http.StatusBadRequest)
|
||||||
|
|
||||||
|
log.Trace().Err(err).Msgf("user_id=`%s`", strUserID)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cart, err := s.cartService.GetItemsByUserID(r.Context(), entity.UID(userID))
|
||||||
|
if err != nil {
|
||||||
|
makeErrorResponse(w, err, http.StatusNotFound)
|
||||||
|
|
||||||
|
log.Trace().Err(err).Msgf("cartService.GetItemsByUserID: %d", http.StatusInternalServerError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := GetUserCartResponse{
|
||||||
|
Items: make([]CartItem, len(cart.Items)),
|
||||||
|
TotalPrice: cart.TotalPrice,
|
||||||
|
}
|
||||||
|
for i, v := range cart.Items {
|
||||||
|
response.Items[i] = CartItem{
|
||||||
|
Sku: int64(v.Product.Sku),
|
||||||
|
Name: v.Product.Name,
|
||||||
|
Count: v.Count,
|
||||||
|
Price: uint32(v.Product.Price),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||||
|
makeErrorResponse(w, err, http.StatusInternalServerError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
25
cart/internal/app/server/server.go
Normal file
25
cart/internal/app/server/server.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"route256/cart/internal/domain/entity"
|
||||||
|
"route256/cart/internal/domain/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CartService interface {
|
||||||
|
AddItem(ctx context.Context, userID entity.UID, item *model.Item) error
|
||||||
|
GetItemsByUserID(ctx context.Context, userID entity.UID) (*model.Cart, error)
|
||||||
|
DeleteItem(ctx context.Context, userID entity.UID, sku entity.Sku) error
|
||||||
|
DeleteItemsByUserID(ctx context.Context, userID entity.UID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
cartService CartService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(cartService CartService) *Server {
|
||||||
|
return &Server{
|
||||||
|
cartService: cartService,
|
||||||
|
}
|
||||||
|
}
|
||||||
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"`
|
||||||
|
}
|
||||||
64
cart/internal/infra/config/config.go
Normal file
64
cart/internal/infra/config/config.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Service struct {
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port string `yaml:"port"`
|
||||||
|
LogLevel string `yaml:"log_level"`
|
||||||
|
Workers int `yaml:"workers"`
|
||||||
|
} `yaml:"service"`
|
||||||
|
|
||||||
|
Jaeger struct {
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port string `yaml:"port"`
|
||||||
|
} `yaml:"jaeger"`
|
||||||
|
|
||||||
|
ProductService struct {
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port string `yaml:"port"`
|
||||||
|
Token string `yaml:"token"`
|
||||||
|
Limit int `yaml:"limit"`
|
||||||
|
Burst int `yaml:"burst"`
|
||||||
|
} `yaml:"product_service"`
|
||||||
|
|
||||||
|
LomsService struct {
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port string `yaml:"port"`
|
||||||
|
} `yaml:"loms_service"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfig(filename string) (*Config, error) {
|
||||||
|
workDir, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfgRoot := filepath.Join(workDir, "configs")
|
||||||
|
absCfgRoot, _ := filepath.Abs(cfgRoot)
|
||||||
|
|
||||||
|
filePath := filepath.Join(absCfgRoot, filepath.Clean(filename))
|
||||||
|
if !strings.HasPrefix(filePath, absCfgRoot) {
|
||||||
|
return nil, fmt.Errorf("invalid path")
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
config := &Config{}
|
||||||
|
if err := yaml.NewDecoder(f).Decode(config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
26
cart/internal/infra/http/middlewares/time_middleware.go
Normal file
26
cart/internal/infra/http/middlewares/time_middleware.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package middlewares
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TimerMiddleware struct {
|
||||||
|
h http.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTimerMiddleware(h http.Handler) http.Handler {
|
||||||
|
return &TimerMiddleware{
|
||||||
|
h: h,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *TimerMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer func(now time.Time) {
|
||||||
|
log.Debug().Msgf("%s %s spent %s", r.Method, r.URL.String(), time.Since(now))
|
||||||
|
}(time.Now())
|
||||||
|
|
||||||
|
m.h.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
23
cart/internal/infra/http/round_trippers/log_rt.go
Normal file
23
cart/internal/infra/http/round_trippers/log_rt.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package round_trippers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LogRoundTripper struct {
|
||||||
|
rt http.RoundTripper
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLogRoundTripper(rt http.RoundTripper) http.RoundTripper {
|
||||||
|
return &LogRoundTripper{
|
||||||
|
rt: rt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LogRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||||
|
log.Debug().Msgf("%s called", r.URL.String())
|
||||||
|
|
||||||
|
return l.rt.RoundTrip(r)
|
||||||
|
}
|
||||||
67
cart/internal/infra/http/round_trippers/retry_rt.go
Normal file
67
cart/internal/infra/http/round_trippers/retry_rt.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package round_trippers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RetryRoundTripper struct {
|
||||||
|
rt http.RoundTripper
|
||||||
|
maxRetries int
|
||||||
|
initialDelaySec int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRetryRoundTripper(rt http.RoundTripper, maxRetries int, initialDelaySec int) http.RoundTripper {
|
||||||
|
return &RetryRoundTripper{
|
||||||
|
rt: rt,
|
||||||
|
maxRetries: maxRetries,
|
||||||
|
initialDelaySec: initialDelaySec,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rrt *RetryRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||||
|
var resp *http.Response
|
||||||
|
var err error
|
||||||
|
|
||||||
|
for attempt := 0; attempt <= rrt.maxRetries; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
// exponential retry time (e.g.: [1s, 2s, 4s])
|
||||||
|
delay := time.Duration(rrt.initialDelaySec<<uint(attempt-1)) * time.Second
|
||||||
|
|
||||||
|
log.Debug().Msgf("retrying, delay=%d", delay)
|
||||||
|
|
||||||
|
timer := time.NewTimer(delay)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
case <-r.Context().Done():
|
||||||
|
timer.Stop()
|
||||||
|
|
||||||
|
return nil, r.Context().Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err = rrt.rt.RoundTrip(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusTooManyRequests, 420:
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if attempt == rrt.maxRetries {
|
||||||
|
return nil, fmt.Errorf("request returned %d after %d retries", resp.StatusCode, rrt.maxRetries)
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
14
docker-compose.yaml
Normal file
14
docker-compose.yaml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
version: "3.7"
|
||||||
|
|
||||||
|
services:
|
||||||
|
cart:
|
||||||
|
build: cart/
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
depends_on:
|
||||||
|
- product-service
|
||||||
|
|
||||||
|
product-service:
|
||||||
|
image: gitlab-registry.ozon.dev/go/classroom-18/students/homework-draft/products:latest
|
||||||
|
ports:
|
||||||
|
- "8082:8082"
|
||||||
8
go.work.sum
Normal file
8
go.work.sum
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
|
||||||
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
Reference in New Issue
Block a user