package main import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "strconv" "sync" "time" "git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/mats/go-cart-actor/pkg/cart" messages "git.k6n.net/mats/go-cart-actor/proto/cart" "git.k6n.net/mats/go-cart-actor/pkg/voucher" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/anypb" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/trace" ) // Pool metrics (cart_grain_spawned_total, cart_grain_lookups_total, // cart_mutations_total, cart_remote_negotiation_total, // cart_connected_remotes, …) are bumped centrally by SimpleGrainPool // — see pkg/actor/simple_grain_pool.go. The cart Metrics is // registered once in cmd/cart/main.go and shared with DiskStorage // via SetMetrics, so the per-handler counters this file used to // maintain are no longer needed. type PoolServer struct { actor.GrainPool[cart.CartGrain] pod_name string // idx is the bus-fed catalog projection cache, consulted at HTTP-fetch // sites (AddSkuToCartHandler, buildItemGroups) to overlay authoritative // price / tax_class / display fields onto AddItem before mutation. nil is // tolerated so handlers still serve if the bus consumer is unavailable // (orphan / quarantine / CI), with a plain cache-miss fall-through. idx *catalogProjectionCache } func NewPoolServer(pool actor.GrainPool[cart.CartGrain], pod_name string, idx *catalogProjectionCache) *PoolServer { srv := &PoolServer{ GrainPool: pool, pod_name: pod_name, idx: idx, } return srv } func (s *PoolServer) ApplyLocal(ctx context.Context, id cart.CartId, mutation ...proto.Message) (*actor.MutationResult[cart.CartGrain], error) { return s.Apply(ctx, uint64(id), mutation...) } func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error { grain, err := s.Get(r.Context(), uint64(id)) if err != nil { return err } return s.WriteResult(w, grain) } func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error { sku := r.PathValue("sku") country := getCountryFromHost(r.Host) if s.idx == nil { log.Printf("No index present") } if s.idx != nil && s.idx.IsDeleted(sku) { // Bus-deleted SKU — skip the HTTP fetch and reject the add. Mirrors the // product-fetcher's "product service returned %d for sku %s" shape so // upstream code that pattern-matches that string still works, AND we // publish StatusNotFound so the HTTP response carries the right code // (the handler's error-return path otherwise renders as 500). http.Error // is the idiomatic stdlib call here — it does the encoding-safe text body // (no fmt.Sprintf/JSON-string-escaping fragility). http.Error(w, fmt.Sprintf("product service returned %d for sku %s (bus-deleted)", 404, sku), http.StatusNotFound) return nil } // Cache-only fast path: AddSkuToCartHandler is a SINGLE top-level add // (qty=1, no children). When the projection carries full authoritative // fields, we skip PRODUCT_BASE_URL entirely and build the AddItem // directly from the cache. This eliminates one HTTP round-trip per // add-to-cart under steady-state bus-fed conditions. var msg *messages.AddItem if s.idx != nil { if p, ok := s.idx.Get(sku); ok && HasRequiredFields(p) { msg = BuildAddItemFromCache(sku, 1, country, nil, p) } } if msg == nil { if s.idx == nil { log.Printf("No item found in index %s", sku) } var err error msg, err = GetItemAddMessage(r.Context(), sku, 1, country, nil) if err != nil { return err } if s.idx != nil { if p, ok := s.idx.Get(sku); ok { ApplyProjectionOverlay(msg, p) } } } data, err := s.ApplyLocal(r.Context(), id, msg) if err != nil { return err } return s.WriteResult(w, data) } func (s *PoolServer) WriteResult(w http.ResponseWriter, result any) error { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("X-Pod-Name", s.pod_name) if result == nil { w.WriteHeader(http.StatusInternalServerError) return nil } w.WriteHeader(http.StatusOK) enc := json.NewEncoder(w) err := enc.Encode(result) return err } func (s *PoolServer) DeleteItemHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error { itemIdString := r.PathValue("itemId") itemId, err := strconv.ParseInt(itemIdString, 10, 64) if err != nil { return err } data, err := s.ApplyLocal(r.Context(), id, &messages.RemoveItem{Id: uint32(itemId)}) if err != nil { return err } return s.WriteResult(w, data) } func (s *PoolServer) QuantityChangeHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error { changeQuantity := messages.ChangeQuantity{} err := json.NewDecoder(r.Body).Decode(&changeQuantity) if err != nil { return err } reply, err := s.ApplyLocal(r.Context(), id, &changeQuantity) if err != nil { return err } return s.WriteResult(w, reply) } type Item struct { Sku string `json:"sku"` Quantity int `json:"quantity"` StoreId *string `json:"storeId,omitempty"` // Children are sub-articles (accessories, services, insurance, ...) priced // relative to this item. They are added as separate cart lines whose // ParentId points to this item's line-item id. Children []Item `json:"children,omitempty"` // CustomFields are optional user-supplied input fields for this line. CustomFields map[string]string `json:"customFields,omitempty"` } type SetCartItems struct { Country string `json:"country"` Items []Item `json:"items"` } // itemGroup is a top-level item together with its child sub-articles. The child // AddItem messages are built with their price already derived from the parent // product; their ParentId is filled in after the parent line is applied. type itemGroup struct { parent *messages.AddItem children []*messages.AddItem } // buildItemGroups fetches every product and builds the AddItem messages. Groups // are built concurrently; within a group the parent is fetched first so it can // drive child pricing, then children are fetched concurrently. Input order is // preserved. Items that fail to fetch are skipped (logged), matching the prior // best-effort behavior. // // idx is the bus-fed projection cache; when non-nil, every AddItem built here // has its cache-covered fields (price/tax_class/display/inventory_tracked/ // drop_ship) overlaid onto the HTTP-fetched answer. Cache-hit calls naturally // become authoritative for what the projection carries; HTTP stays for // dimensions (parent width/height for child pricing), seller/orgPrice and the // dynamic ExtraJson product data. Skipped cleanly when idx is nil. // // Cache-only fast path: a parent with NO children AND a cache hit that // satisfies HasRequiredFields skips PRODUCT_BASE_URL entirely — BuildAddItem- // FromCache constructs the line directly from the Projection. This eliminates // the per-add HTTP round-trip under steady-state bus-fed conditions for the // common top-level add case. The HTTP path continues to handle: // - cache miss (cold-grace; a SKU not yet bus-fed) // - HasRequiredFields==false (partial bus delivery, e.g. TaxClass missing // because the producer didn't classify) // - parents WITH children (configurator behavior preservation — child // price = child per-m² rate × parent area, and parent area comes from // HTTP-fetched width/height that the Projection doesn't carry yet; a // future schema extension can unblock a child cache-only path). func buildItemGroups(ctx context.Context, items []Item, country string, idx *catalogProjectionCache) []itemGroup { groups := make([]itemGroup, len(items)) wg := sync.WaitGroup{} for i, itm := range items { wg.Go(func() { if idx != nil && idx.IsDeleted(itm.Sku) { log.Printf("error adding item %s: bus-deleted (skipping)", itm.Sku) return } // Cache-only fast path: see package doc above for the eligibility // contract. var parentMsg *messages.AddItem var parentProduct *ProductItem if len(itm.Children) == 0 && idx != nil { if p, ok := idx.Get(itm.Sku); ok && HasRequiredFields(p) { parentMsg = BuildAddItemFromCache(itm.Sku, itm.Quantity, country, itm.StoreId, p) } } if parentMsg == nil { var err error parentMsg, parentProduct, err = BuildItemMessage(ctx, itm.Sku, itm.Quantity, country, itm.StoreId, nil) if err != nil { log.Printf("error adding item %s: %v", itm.Sku, err) return } if idx != nil { if p, ok := idx.Get(itm.Sku); ok { ApplyProjectionOverlay(parentMsg, p) } } } parentMsg.CustomFields = itm.CustomFields groups[i].parent = parentMsg if len(itm.Children) == 0 { return } children := make([]*messages.AddItem, len(itm.Children)) cwg := sync.WaitGroup{} for j, child := range itm.Children { cwg.Go(func() { if idx != nil && idx.IsDeleted(child.Sku) { log.Printf("error adding child %s of %s: bus-deleted (skipping)", child.Sku, itm.Sku) return } childMsg, _, err := BuildItemMessage(ctx, child.Sku, child.Quantity, country, child.StoreId, parentProduct) if err != nil { log.Printf("error adding child %s of %s: %v", child.Sku, itm.Sku, err) return } if idx != nil { if p, ok := idx.Get(child.Sku); ok { ApplyProjectionOverlay(childMsg, p) } } childMsg.CustomFields = child.CustomFields children[j] = childMsg }) } cwg.Wait() for _, c := range children { if c != nil { groups[i].children = append(groups[i].children, c) } } }) } wg.Wait() return groups } // applyItemGroups applies each group in dependency order: the parent first, then // its children with ParentId set to the parent's resolved line-item id. Returns // the last mutation result for rendering. func (s *PoolServer) applyItemGroups(ctx context.Context, id cart.CartId, groups []itemGroup) (*actor.MutationResult[cart.CartGrain], error) { var last *actor.MutationResult[cart.CartGrain] for _, g := range groups { if g.parent == nil { continue } res, err := s.ApplyLocal(ctx, id, g.parent) if err != nil { return nil, err } last = res if len(g.children) == 0 { continue } parentLineId, ok := parentLineId(&res.Result, g.parent) if !ok { log.Printf("could not resolve parent line for item %d (sku %s); skipping %d children", g.parent.ItemId, g.parent.Sku, len(g.children)) continue } childMsgs := make([]proto.Message, len(g.children)) for i, c := range g.children { c.ParentId = &parentLineId childMsgs[i] = c } res, err = s.ApplyLocal(ctx, id, childMsgs...) if err != nil { return nil, err } last = res } return last, nil } // parentLineId finds the line-item id of the just-applied parent: the resident // item with the parent's catalog ItemId, matching store, and no parent of its // own. AddItem merges by sku+store, so at most one such line exists. func parentLineId(grain *cart.CartGrain, parent *messages.AddItem) (uint32, bool) { for _, it := range grain.Items { if it == nil || it.ParentId != nil { continue } if it.ItemId != parent.ItemId { continue } sameStore := (it.StoreId == nil && parent.StoreId == nil) || (it.StoreId != nil && parent.StoreId != nil && *it.StoreId == *parent.StoreId) if sameStore { return it.Id, true } } return 0, false } func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error { setCartItems := SetCartItems{} err := json.NewDecoder(r.Body).Decode(&setCartItems) if err != nil { return err } if _, err := s.ApplyLocal(r.Context(), id, &messages.ClearCartRequest{}); err != nil { return err } groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country, s.idx) reply, err := s.applyItemGroups(r.Context(), id, groups) if err != nil { return err } if reply == nil { // Cart was cleared but nothing added; return current (empty) state. grain, err := s.Get(r.Context(), uint64(id)) if err != nil { return err } return s.WriteResult(w, grain) } return s.WriteResult(w, reply) } func (s *PoolServer) AddMultipleItemHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error { setCartItems := SetCartItems{} err := json.NewDecoder(r.Body).Decode(&setCartItems) if err != nil { return err } groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country, s.idx) reply, err := s.applyItemGroups(r.Context(), id, groups) if err != nil { return err } if reply == nil { grain, err := s.Get(r.Context(), uint64(id)) if err != nil { return err } return s.WriteResult(w, grain) } return s.WriteResult(w, reply) } type AddRequest struct { Sku string `json:"sku"` Quantity int32 `json:"quantity"` Country string `json:"country"` StoreId *string `json:"storeId"` // Children are sub-articles priced relative to this item (see Item.Children). Children []Item `json:"children,omitempty"` // CustomFields are optional user-supplied input fields for this line. CustomFields map[string]string `json:"customFields,omitempty"` } func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error { addRequest := AddRequest{Quantity: 1} err := json.NewDecoder(r.Body).Decode(&addRequest) if err != nil { return err } item := Item{ Sku: addRequest.Sku, Quantity: int(addRequest.Quantity), StoreId: addRequest.StoreId, Children: addRequest.Children, CustomFields: addRequest.CustomFields, } groups := buildItemGroups(r.Context(), []Item{item}, addRequest.Country, s.idx) reply, err := s.applyItemGroups(r.Context(), id, groups) if err != nil { return err } if reply == nil { grain, err := s.Get(r.Context(), uint64(id)) if err != nil { return err } return s.WriteResult(w, grain) } return s.WriteResult(w, reply) } // func (s *PoolServer) HandleConfirmation(w http.ResponseWriter, r *http.Request, id CartId) error { // orderId := r.PathValue("orderId") // if orderId == "" { // return fmt.Errorf("orderId is empty") // } // order, err := KlarnaInstance.GetOrder(orderId) // if err != nil { // return err // } // w.Header().Set("Content-Type", "application/json") // w.Header().Set("X-Pod-Name", s.pod_name) // w.Header().Set("Cache-Control", "no-cache") // w.Header().Set("Access-Control-Allow-Origin", "*") // w.WriteHeader(http.StatusOK) // return json.NewEncoder(w).Encode(order) // } // func (s *PoolServer) HandleCheckout(w http.ResponseWriter, r *http.Request, id CartId) error { // klarnaOrder, err := s.CreateOrUpdateCheckout(r.Host, id) // if err != nil { // return err // } // s.ApplyCheckoutStarted(klarnaOrder, id) // w.Header().Set("Content-Type", "application/json") // return json.NewEncoder(w).Encode(klarnaOrder) // } // // Removed leftover legacy block after CookieCartIdHandler (obsolete code referencing cid/legacy) func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error { // Clear cart cookie (breaking change: do not issue a new legacy id here) http.SetCookie(w, &http.Cookie{ Name: "cartid", Value: "", Path: "/", Secure: r.TLS != nil, HttpOnly: true, Expires: time.Unix(0, 0), SameSite: http.SameSiteLaxMode, }) w.WriteHeader(http.StatusOK) return nil } func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error) func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error { return func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error { if ownerHost, ok := s.OwnerHost(uint64(cartId)); ok { ctx, span := tracer.Start(r.Context(), "proxy") defer span.End() span.SetAttributes(attribute.String("cartid", cartId.String())) hostAttr := attribute.String("other host", ownerHost.Name()) span.SetAttributes(hostAttr) proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr)) handled, err := ownerHost.Proxy(uint64(cartId), w, r, nil) if err == nil && handled { return nil } } _, span := tracer.Start(r.Context(), "own") span.SetAttributes(attribute.String("cartid", cartId.String())) defer span.End() return fn(w, r, cartId) } } var ( tracer = otel.Tracer(name) meter = otel.Meter(name) proxyCalls metric.Int64Counter ) func init() { var err error proxyCalls, err = meter.Int64Counter("proxy.calls", metric.WithDescription("Number of proxy calls"), metric.WithUnit("{calls}")) if err != nil { panic(err) } } type AddVoucherRequest struct { VoucherCode string `json:"code"` } func (s *PoolServer) AddVoucherHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error { data := &AddVoucherRequest{} json.NewDecoder(r.Body).Decode(data) v := voucher.Service{} msg, err := v.GetVoucher(data.VoucherCode) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) return err } reply, err := s.ApplyLocal(r.Context(), cartId, msg) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) return err } s.WriteResult(w, reply) return nil } type SubscriptionDetailsRequest struct { Id *string `json:"id,omitempty"` OfferingCode string `json:"offeringCode,omitempty"` SigningType string `json:"signingType,omitempty"` Data json.RawMessage `json:"data,omitempty"` } func (sd *SubscriptionDetailsRequest) ToMessage() *messages.UpsertSubscriptionDetails { return &messages.UpsertSubscriptionDetails{ Id: sd.Id, OfferingCode: sd.OfferingCode, SigningType: sd.SigningType, Data: &anypb.Any{Value: sd.Data}, } } func (s *PoolServer) SubscriptionDetailsHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error { data := &SubscriptionDetailsRequest{} err := json.NewDecoder(r.Body).Decode(data) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) return err } reply, err := s.ApplyLocal(r.Context(), cartId, data.ToMessage()) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) return err } s.WriteResult(w, reply) return nil } func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error { idStr := r.PathValue("voucherId") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) return err } reply, err := s.ApplyLocal(r.Context(), cartId, &messages.RemoveVoucher{Id: uint32(id)}) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) return err } s.WriteResult(w, reply) return nil } // SetRecoveryContactRequest is the wire shape clients send to // PUT /cart/recovery-contact. It mirrors proto SetRecoveryContact with // JSON-friendly types; sending an empty JSON object clears all contact info. type SetRecoveryContactRequest struct { Email string `json:"email,omitempty"` PushTokens []cart.PushToken `json:"pushTokens,omitempty"` } // toMessage builds the proto message from the JSON request body. func (s *SetRecoveryContactRequest) toMessage() *messages.SetRecoveryContact { out := &messages.SetRecoveryContact{Email: s.Email} if len(s.PushTokens) > 0 { out.PushTokens = make([]*messages.PushToken, 0, len(s.PushTokens)) for _, t := range s.PushTokens { out.PushTokens = append(out.PushTokens, &messages.PushToken{ Platform: t.Platform, Token: t.Token, }) } } return out } func (s *PoolServer) SetRecoveryContactHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error { if r.Method != http.MethodPut { w.WriteHeader(http.StatusMethodNotAllowed) return nil } req := SetRecoveryContactRequest{} if err := json.NewDecoder(r.Body).Decode(&req); err != nil { w.WriteHeader(http.StatusBadRequest) w.Write([]byte(err.Error())) return err } reply, err := s.ApplyLocal(r.Context(), cartId, req.toMessage()) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) return err } return s.WriteResult(w, reply) } func (s *PoolServer) SetUserIdHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error { setUserId := messages.SetUserId{} err := json.NewDecoder(r.Body).Decode(&setUserId) if err != nil { return err } reply, err := s.ApplyLocal(r.Context(), cartId, &setUserId) if err != nil { return err } return s.WriteResult(w, reply) } func (s *PoolServer) LineItemMarkingHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error { itemIdStr := r.PathValue("itemId") itemId, err := strconv.ParseInt(itemIdStr, 10, 64) if err != nil { return err } lineItemMarking := messages.LineItemMarking{Id: uint32(itemId)} err = json.NewDecoder(r.Body).Decode(&lineItemMarking) if err != nil { return err } reply, err := s.ApplyLocal(r.Context(), cartId, &lineItemMarking) if err != nil { return err } return s.WriteResult(w, reply) } func (s *PoolServer) RemoveLineItemMarkingHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error { itemIdStr := r.PathValue("itemId") itemId, err := strconv.ParseInt(itemIdStr, 10, 64) if err != nil { return err } removeLineItemMarking := messages.RemoveLineItemMarking{Id: uint32(itemId)} reply, err := s.ApplyLocal(r.Context(), cartId, &removeLineItemMarking) if err != nil { return err } return s.WriteResult(w, reply) } type SetCustomFieldsRequest struct { CustomFields map[string]string `json:"customFields"` } func (s *PoolServer) SetCustomFieldsHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error { itemId, err := strconv.ParseInt(r.PathValue("itemId"), 10, 64) if err != nil { return err } req := SetCustomFieldsRequest{} if err := json.NewDecoder(r.Body).Decode(&req); err != nil { return err } reply, err := s.ApplyLocal(r.Context(), cartId, &messages.SetLineItemCustomFields{ Id: uint32(itemId), CustomFields: req.CustomFields, }) if err != nil { return err } return s.WriteResult(w, reply) } func (s *PoolServer) InternalApplyMutationHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return nil } data, err := io.ReadAll(r.Body) if err != nil { return err } mutation := &messages.Mutation{} err = proto.Unmarshal(data, mutation) if err != nil { return err } reply, err := s.ApplyLocal(r.Context(), cartId, mutation) if err != nil { return err } return s.WriteResult(w, reply) } func (s *PoolServer) GetAnywhere(ctx context.Context, cartId cart.CartId) (*cart.CartGrain, error) { id := uint64(cartId) if host, isOnOtherHost := s.OwnerHost(id); isOnOtherHost { return host.Get(ctx, id) } return s.Get(ctx, id) } func (s *PoolServer) ApplyAnywhere(ctx context.Context, cartId cart.CartId, msgs ...proto.Message) error { id := uint64(cartId) if host, isOnOtherHost := s.OwnerHost(id); isOnOtherHost { _, err := host.Apply(ctx, id, msgs...) return err } _, err := s.Apply(ctx, id, msgs...) return err } func (s *PoolServer) Serve(mux *http.ServeMux) { // mux.HandleFunc("OPTIONS /cart", func(w http.ResponseWriter, r *http.Request) { // w.Header().Set("Access-Control-Allow-Origin", "*") // w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE") // w.Header().Set("Access-Control-Allow-Headers", "Content-Type") // w.WriteHeader(http.StatusOK) // }) handleFunc := func(pattern string, handlerFunc func(http.ResponseWriter, *http.Request)) { attr := attribute.String("http.route", pattern) mux.HandleFunc(pattern, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { span := trace.SpanFromContext(r.Context()) span.SetName(pattern) span.SetAttributes(attr) labeler, _ := otelhttp.LabelerFromContext(r.Context()) labeler.Add(attr) handlerFunc(w, r) })) } handleFunc("GET /cart", CookieCartIdHandler(s.ProxyHandler(s.GetCartHandler))) handleFunc("GET /cart/add/{sku}", CookieCartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler))) handleFunc("POST /cart/add", CookieCartIdHandler(s.ProxyHandler(s.AddMultipleItemHandler))) handleFunc("POST /cart", CookieCartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler))) handleFunc("POST /cart/set", CookieCartIdHandler(s.ProxyHandler(s.SetCartItemsHandler))) handleFunc("DELETE /cart/{itemId}", CookieCartIdHandler(s.ProxyHandler(s.DeleteItemHandler))) handleFunc("PUT /cart", CookieCartIdHandler(s.ProxyHandler(s.QuantityChangeHandler))) handleFunc("DELETE /cart", CookieCartIdHandler(s.ProxyHandler(s.RemoveCartCookie))) handleFunc("PUT /cart/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler))) handleFunc("PUT /cart/subscription-details", CookieCartIdHandler(s.ProxyHandler(s.SubscriptionDetailsHandler))) handleFunc("DELETE /cart/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler))) handleFunc("PUT /cart/user", CookieCartIdHandler(s.ProxyHandler(s.SetUserIdHandler))) // PUT only — SetRecoveryContact is PUT/replace semantics. We deliberately // do NOT allow POST here to avoid conflicts with any future POST→create // semantics on /cart paths. handleFunc("PUT /cart/recovery-contact", CookieCartIdHandler(s.ProxyHandler(s.SetRecoveryContactHandler))) handleFunc("PUT /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler))) handleFunc("DELETE /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler))) handleFunc("PUT /cart/item/{itemId}/custom-fields", CookieCartIdHandler(s.ProxyHandler(s.SetCustomFieldsHandler))) //mux.HandleFunc("GET /cart/checkout", CookieCartIdHandler(s.ProxyHandler(s.HandleCheckout))) //mux.HandleFunc("GET /cart/confirmation/{orderId}", CookieCartIdHandler(s.ProxyHandler(s.HandleConfirmation))) handleFunc("GET /cart/byid/{id}", CartIdHandler(s.ProxyHandler(s.GetCartHandler))) handleFunc("POST /cart/byid/{id}", CartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler))) handleFunc("DELETE /cart/byid/{id}/{itemId}", CartIdHandler(s.ProxyHandler(s.DeleteItemHandler))) handleFunc("PUT /cart/byid/{id}", CartIdHandler(s.ProxyHandler(s.QuantityChangeHandler))) handleFunc("PUT /cart/byid/{id}/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler))) handleFunc("DELETE /cart/byid/{id}/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler))) handleFunc("PUT /cart/byid/{id}/user", CartIdHandler(s.ProxyHandler(s.SetUserIdHandler))) handleFunc("PUT /cart/byid/{id}/recovery-contact", CartIdHandler(s.ProxyHandler(s.SetRecoveryContactHandler))) handleFunc("PUT /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler))) handleFunc("DELETE /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler))) handleFunc("PUT /cart/byid/{id}/item/{itemId}/custom-fields", CartIdHandler(s.ProxyHandler(s.SetCustomFieldsHandler))) }