mirror of
				https://github.com/3ybactuk/marketplace-go-service-project.git
				synced 2025-10-30 14:03:45 +03:00 
			
		
		
		
	Merge branch 'hw-1' into 'master'
[hw-1] implement cart service See merge request nvshubin1/homework!1
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
	 Никита Шубин
					Никита Шубин