From 5077f04b0c4c65016f85dd85fa63aeb120e5b7c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9D=D0=B8=D0=BA=D0=B8=D1=82=D0=B0=20=D0=A8=D1=83=D0=B1?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Sun, 25 May 2025 15:49:17 +0000 Subject: [PATCH] [hw-1] implement cart service --- Makefile | 4 + cart/Dockerfile | 20 +++ cart/cmd/server/main.go | 18 ++ cart/configs/values_local.yaml | 4 +- cart/go.mod | 22 +++ cart/go.sum | 37 ++++ cart/internal/app/app.go | 104 +++++++++++ cart/internal/app/server/add_item_handler.go | 96 ++++++++++ .../app/server/delete_cart_handler.go | 37 ++++ .../app/server/delete_item_handler.go | 55 ++++++ cart/internal/app/server/error_response.go | 24 +++ cart/internal/app/server/get_cart_handler.go | 70 ++++++++ cart/internal/app/server/server.go | 25 +++ .../cart/repository/in_memory_repository.go | 118 +++++++++++++ cart/internal/domain/cart/service/service.go | 165 ++++++++++++++++++ cart/internal/domain/entity/cart.go | 12 ++ cart/internal/domain/model/cart.go | 9 + cart/internal/domain/model/errors.go | 10 ++ cart/internal/domain/model/items.go | 16 ++ cart/internal/domain/model/product.go | 21 +++ cart/internal/domain/model/validate.go | 9 + .../products/service/product_service.go | 75 ++++++++ cart/internal/infra/config/config.go | 64 +++++++ .../infra/http/middlewares/time_middleware.go | 26 +++ .../infra/http/round_trippers/log_rt.go | 23 +++ .../infra/http/round_trippers/retry_rt.go | 67 +++++++ docker-compose.yaml | 14 ++ go.work.sum | 8 + 28 files changed, 1151 insertions(+), 2 deletions(-) create mode 100644 cart/Dockerfile create mode 100644 cart/cmd/server/main.go create mode 100644 cart/go.sum create mode 100644 cart/internal/app/app.go create mode 100644 cart/internal/app/server/add_item_handler.go create mode 100644 cart/internal/app/server/delete_cart_handler.go create mode 100644 cart/internal/app/server/delete_item_handler.go create mode 100644 cart/internal/app/server/error_response.go create mode 100644 cart/internal/app/server/get_cart_handler.go create mode 100644 cart/internal/app/server/server.go create mode 100644 cart/internal/domain/cart/repository/in_memory_repository.go create mode 100644 cart/internal/domain/cart/service/service.go create mode 100644 cart/internal/domain/entity/cart.go create mode 100644 cart/internal/domain/model/cart.go create mode 100644 cart/internal/domain/model/errors.go create mode 100644 cart/internal/domain/model/items.go create mode 100644 cart/internal/domain/model/product.go create mode 100644 cart/internal/domain/model/validate.go create mode 100644 cart/internal/domain/products/service/product_service.go create mode 100644 cart/internal/infra/config/config.go create mode 100644 cart/internal/infra/http/middlewares/time_middleware.go create mode 100644 cart/internal/infra/http/round_trippers/log_rt.go create mode 100644 cart/internal/infra/http/round_trippers/retry_rt.go create mode 100644 docker-compose.yaml create mode 100644 go.work.sum diff --git a/Makefile b/Makefile index 6eca5bb..a289261 100644 --- a/Makefile +++ b/Makefile @@ -4,3 +4,7 @@ include make/build.mk lint: cart-lint loms-lint notifier-lint comments-lint build: cart-build loms-build notifier-build comments-build + +run-all: + echo "starting build" + docker-compose up --build diff --git a/cart/Dockerfile b/cart/Dockerfile new file mode 100644 index 0000000..051d65e --- /dev/null +++ b/cart/Dockerfile @@ -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"] diff --git a/cart/cmd/server/main.go b/cart/cmd/server/main.go new file mode 100644 index 0000000..ad58d3b --- /dev/null +++ b/cart/cmd/server/main.go @@ -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) + } +} diff --git a/cart/configs/values_local.yaml b/cart/configs/values_local.yaml index 7b7d7dd..c8a99bb 100644 --- a/cart/configs/values_local.yaml +++ b/cart/configs/values_local.yaml @@ -1,6 +1,6 @@ service: - host: localhost port: 8080 + log_level: trace workers: 5 jaeger: @@ -8,7 +8,7 @@ jaeger: port: 6831 product_service: - host: localhost + host: product-service port: 8082 token: testToken limit: 10 diff --git a/cart/go.mod b/cart/go.mod index 24f18af..04a1ed6 100644 --- a/cart/go.mod +++ b/cart/go.mod @@ -1,3 +1,25 @@ module route256/cart 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 +) diff --git a/cart/go.sum b/cart/go.sum new file mode 100644 index 0000000..442862f --- /dev/null +++ b/cart/go.sum @@ -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= diff --git a/cart/internal/app/app.go b/cart/internal/app/app.go new file mode 100644 index 0000000..7dc9bfe --- /dev/null +++ b/cart/internal/app/app.go @@ -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 +} diff --git a/cart/internal/app/server/add_item_handler.go b/cart/internal/app/server/add_item_handler.go new file mode 100644 index 0000000..7d7e390 --- /dev/null +++ b/cart/internal/app/server/add_item_handler.go @@ -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) +} diff --git a/cart/internal/app/server/delete_cart_handler.go b/cart/internal/app/server/delete_cart_handler.go new file mode 100644 index 0000000..b5a24a2 --- /dev/null +++ b/cart/internal/app/server/delete_cart_handler.go @@ -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) +} diff --git a/cart/internal/app/server/delete_item_handler.go b/cart/internal/app/server/delete_item_handler.go new file mode 100644 index 0000000..9411bc9 --- /dev/null +++ b/cart/internal/app/server/delete_item_handler.go @@ -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 + } +} diff --git a/cart/internal/app/server/error_response.go b/cart/internal/app/server/error_response.go new file mode 100644 index 0000000..0227df2 --- /dev/null +++ b/cart/internal/app/server/error_response.go @@ -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 + } +} diff --git a/cart/internal/app/server/get_cart_handler.go b/cart/internal/app/server/get_cart_handler.go new file mode 100644 index 0000000..bb5182a --- /dev/null +++ b/cart/internal/app/server/get_cart_handler.go @@ -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 + } +} diff --git a/cart/internal/app/server/server.go b/cart/internal/app/server/server.go new file mode 100644 index 0000000..336ef09 --- /dev/null +++ b/cart/internal/app/server/server.go @@ -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, + } +} diff --git a/cart/internal/domain/cart/repository/in_memory_repository.go b/cart/internal/domain/cart/repository/in_memory_repository.go new file mode 100644 index 0000000..dcaf8c9 --- /dev/null +++ b/cart/internal/domain/cart/repository/in_memory_repository.go @@ -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 +} diff --git a/cart/internal/domain/cart/service/service.go b/cart/internal/domain/cart/service/service.go new file mode 100644 index 0000000..e5e6790 --- /dev/null +++ b/cart/internal/domain/cart/service/service.go @@ -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 +} diff --git a/cart/internal/domain/entity/cart.go b/cart/internal/domain/entity/cart.go new file mode 100644 index 0000000..0c6d2ca --- /dev/null +++ b/cart/internal/domain/entity/cart.go @@ -0,0 +1,12 @@ +package entity + +type ( + UID uint64 + Sku int64 +) + +type Cart struct { + UserID UID + Items []Sku + ItemCount map[Sku]uint32 +} diff --git a/cart/internal/domain/model/cart.go b/cart/internal/domain/model/cart.go new file mode 100644 index 0000000..a3fcc2e --- /dev/null +++ b/cart/internal/domain/model/cart.go @@ -0,0 +1,9 @@ +package model + +import "route256/cart/internal/domain/entity" + +type Cart struct { + UserID entity.UID + Items []*Item + TotalPrice uint32 +} diff --git a/cart/internal/domain/model/errors.go b/cart/internal/domain/model/errors.go new file mode 100644 index 0000000..391a118 --- /dev/null +++ b/cart/internal/domain/model/errors.go @@ -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") +) diff --git a/cart/internal/domain/model/items.go b/cart/internal/domain/model/items.go new file mode 100644 index 0000000..8294bbb --- /dev/null +++ b/cart/internal/domain/model/items.go @@ -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 +} diff --git a/cart/internal/domain/model/product.go b/cart/internal/domain/model/product.go new file mode 100644 index 0000000..9faada8 --- /dev/null +++ b/cart/internal/domain/model/product.go @@ -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 +} diff --git a/cart/internal/domain/model/validate.go b/cart/internal/domain/model/validate.go new file mode 100644 index 0000000..993e32c --- /dev/null +++ b/cart/internal/domain/model/validate.go @@ -0,0 +1,9 @@ +package model + +import "github.com/go-playground/validator/v10" + +var validate *validator.Validate + +func init() { + validate = validator.New() +} diff --git a/cart/internal/domain/products/service/product_service.go b/cart/internal/domain/products/service/product_service.go new file mode 100644 index 0000000..de3bcfe --- /dev/null +++ b/cart/internal/domain/products/service/product_service.go @@ -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"` +} diff --git a/cart/internal/infra/config/config.go b/cart/internal/infra/config/config.go new file mode 100644 index 0000000..a72d740 --- /dev/null +++ b/cart/internal/infra/config/config.go @@ -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 +} diff --git a/cart/internal/infra/http/middlewares/time_middleware.go b/cart/internal/infra/http/middlewares/time_middleware.go new file mode 100644 index 0000000..4701f13 --- /dev/null +++ b/cart/internal/infra/http/middlewares/time_middleware.go @@ -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) +} diff --git a/cart/internal/infra/http/round_trippers/log_rt.go b/cart/internal/infra/http/round_trippers/log_rt.go new file mode 100644 index 0000000..578b9df --- /dev/null +++ b/cart/internal/infra/http/round_trippers/log_rt.go @@ -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) +} diff --git a/cart/internal/infra/http/round_trippers/retry_rt.go b/cart/internal/infra/http/round_trippers/retry_rt.go new file mode 100644 index 0000000..fc8e740 --- /dev/null +++ b/cart/internal/infra/http/round_trippers/retry_rt.go @@ -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<