package main import ( "bytes" "encoding/json" "fmt" "log" "net/http" "strconv" "sync" "time" "git.tornberg.me/go-cart-actor/pkg/actor" "git.tornberg.me/go-cart-actor/pkg/cart" messages "git.tornberg.me/go-cart-actor/pkg/messages" "git.tornberg.me/go-cart-actor/pkg/voucher" "github.com/gogo/protobuf/proto" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "go.opentelemetry.io/contrib/bridges/otelslog" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" ) var ( grainMutations = promauto.NewCounter(prometheus.CounterOpts{ Name: "cart_grain_mutations_total", Help: "The total number of mutations", }) grainLookups = promauto.NewCounter(prometheus.CounterOpts{ Name: "cart_grain_lookups_total", Help: "The total number of lookups", }) ) type PoolServer struct { actor.GrainPool[*cart.CartGrain] pod_name string klarnaClient *KlarnaClient } func NewPoolServer(pool actor.GrainPool[*cart.CartGrain], pod_name string, klarnaClient *KlarnaClient) *PoolServer { return &PoolServer{ GrainPool: pool, pod_name: pod_name, klarnaClient: klarnaClient, } } func (s *PoolServer) ApplyLocal(id cart.CartId, mutation ...proto.Message) (*actor.MutationResult[*cart.CartGrain], error) { return s.Apply(uint64(id), mutation...) } func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error { grain, err := s.Get(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") msg, err := GetItemAddMessage(sku, 1, getCountryFromHost(r.Host), nil) if err != nil { return err } data, err := s.ApplyLocal(id, msg) if err != nil { return err } grainMutations.Add(float64(len(data.Mutations))) 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(id, &messages.RemoveItem{Id: uint32(itemId)}) if err != nil { return err } return s.WriteResult(w, data) } type SetDeliveryRequest struct { Provider string `json:"provider"` Items []uint32 `json:"items"` PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"` } func (s *PoolServer) SetDeliveryHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error { delivery := SetDeliveryRequest{} err := json.NewDecoder(r.Body).Decode(&delivery) if err != nil { return err } data, err := s.ApplyLocal(id, &messages.SetDelivery{ Provider: delivery.Provider, Items: delivery.Items, PickupPoint: delivery.PickupPoint, }) if err != nil { return err } return s.WriteResult(w, data) } func (s *PoolServer) SetPickupPointHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error { deliveryIdString := r.PathValue("deliveryId") deliveryId, err := strconv.ParseInt(deliveryIdString, 10, 64) if err != nil { return err } pickupPoint := messages.PickupPoint{} err = json.NewDecoder(r.Body).Decode(&pickupPoint) if err != nil { return err } reply, err := s.ApplyLocal(id, &messages.SetPickupPoint{ DeliveryId: uint32(deliveryId), Id: pickupPoint.Id, Name: pickupPoint.Name, Address: pickupPoint.Address, City: pickupPoint.City, Zip: pickupPoint.Zip, Country: pickupPoint.Country, }) if err != nil { return err } return s.WriteResult(w, reply) } func (s *PoolServer) RemoveDeliveryHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error { deliveryIdString := r.PathValue("deliveryId") deliveryId, err := strconv.Atoi(deliveryIdString) if err != nil { return err } reply, err := s.ApplyLocal(id, &messages.RemoveDelivery{Id: uint32(deliveryId)}) if err != nil { return err } return s.WriteResult(w, reply) } 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(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"` } type SetCartItems struct { Country string `json:"country"` Items []Item `json:"items"` } func getMultipleAddMessages(items []Item, country string) []proto.Message { wg := sync.WaitGroup{} mu := sync.Mutex{} msgs := make([]proto.Message, 0, len(items)) for _, itm := range items { wg.Go( func() { msg, err := GetItemAddMessage(itm.Sku, itm.Quantity, country, itm.StoreId) if err != nil { log.Printf("error adding item %s: %v", itm.Sku, err) return } mu.Lock() msgs = append(msgs, msg) mu.Unlock() }) } wg.Wait() return msgs } 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 } msgs := make([]proto.Message, 0, len(setCartItems.Items)+1) msgs = append(msgs, &messages.ClearCartRequest{}) msgs = append(msgs, getMultipleAddMessages(setCartItems.Items, setCartItems.Country)...) reply, err := s.ApplyLocal(id, msgs...) if err != nil { return err } 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 } reply, err := s.ApplyLocal(id, getMultipleAddMessages(setCartItems.Items, setCartItems.Country)...) if err != nil { return err } return s.WriteResult(w, reply) } type AddRequest struct { Sku string `json:"sku"` Quantity int32 `json:"quantity"` Country string `json:"country"` StoreId *string `json:"storeId"` } 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 } msg, err := GetItemAddMessage(addRequest.Sku, int(addRequest.Quantity), addRequest.Country, addRequest.StoreId) if err != nil { return err } reply, err := s.ApplyLocal(id, msg) if err != nil { return err } 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 getCurrency(country string) string { if country == "no" { return "NOK" } return "SEK" } func getLocale(country string) string { if country == "no" { return "nb-no" } return "sv-se" } func (s *PoolServer) CreateOrUpdateCheckout(host string, id cart.CartId) (*CheckoutOrder, error) { country := getCountryFromHost(host) meta := &CheckoutMeta{ Terms: fmt.Sprintf("https://%s/terms", host), Checkout: fmt.Sprintf("https://%s/checkout?order_id={checkout.order.id}", host), Confirmation: fmt.Sprintf("https://%s/confirmation/{checkout.order.id}", host), Validation: fmt.Sprintf("https://%s/validate", host), Push: fmt.Sprintf("https://%s/push?order_id={checkout.order.id}", host), Country: country, Currency: getCurrency(country), Locale: getLocale(country), } // Get current grain state (may be local or remote) grain, err := s.Get(uint64(id)) if err != nil { return nil, err } // Build pure checkout payload payload, _, err := BuildCheckoutOrderPayload(grain, meta) if err != nil { return nil, err } if grain.OrderReference != "" { return s.klarnaClient.UpdateOrder(grain.OrderReference, bytes.NewReader(payload)) } else { return s.klarnaClient.CreateOrder(bytes.NewReader(payload)) } } func (s *PoolServer) ApplyCheckoutStarted(klarnaOrder *CheckoutOrder, id cart.CartId) (*actor.MutationResult[*cart.CartGrain], error) { // Persist initialization state via mutation (best-effort) return s.ApplyLocal(id, &messages.InitializeCheckout{ OrderId: klarnaOrder.ID, Status: klarnaOrder.Status, PaymentInProgress: true, }) } // 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) // } // func CookieCartIdHandler(fn func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { var id cart.CartId cookie, err := r.Cookie("cartid") if err != nil || cookie.Value == "" { id = cart.MustNewCartId() http.SetCookie(w, &http.Cookie{ Name: "cartid", Value: id.String(), Secure: r.TLS != nil, HttpOnly: true, Path: "/", Expires: time.Now().AddDate(0, 0, 14), SameSite: http.SameSiteLaxMode, }) w.Header().Set("Set-Cart-Id", id.String()) } else { parsed, ok := cart.ParseCartId(cookie.Value) if !ok { id = cart.MustNewCartId() http.SetCookie(w, &http.Cookie{ Name: "cartid", Value: id.String(), Secure: r.TLS != nil, HttpOnly: true, Path: "/", Expires: time.Now().AddDate(0, 0, 14), SameSite: http.SameSiteLaxMode, }) w.Header().Set("Set-Cart-Id", id.String()) } else { id = parsed } } err = fn(id, w, r) if err != nil { log.Printf("Server error, not remote error: %v\n", err) w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) } } } // 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 CartIdHandler(fn func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { var id cart.CartId raw := r.PathValue("id") // If no id supplied, generate a new one if raw == "" { id := cart.MustNewCartId() w.Header().Set("Set-Cart-Id", id.String()) } else { // Parse base62 cart id if parsedId, ok := cart.ParseCartId(raw); !ok { w.WriteHeader(http.StatusBadRequest) w.Write([]byte("cart id is invalid")) return } else { id = parsedId } } err := fn(id, w, r) if err != nil { log.Printf("Server error, not remote error: %v\n", err) w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) } } } 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 { _, span := tracer.Start(r.Context(), "proxy") defer span.End() span.SetAttributes(attribute.String("other host", ownerHost.Name())) handled, err := ownerHost.Proxy(uint64(cartId), w, r) grainLookups.Inc() if err == nil && handled { return nil } } _, span := tracer.Start(r.Context(), "own") defer span.End() return fn(w, r, cartId) } } var ( tracer = otel.Tracer(name) // meter = otel.Meter(name) logger = otelslog.NewLogger(name) // rollCnt metric.Int64Counter ) 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 { s.ApplyLocal(cartId, &messages.PreConditionFailed{ Operation: "AddVoucher", Error: err.Error(), }) w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) return err } reply, err := s.ApplyLocal(cartId, msg) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) return err } s.WriteResult(w, reply) return nil } func (s *PoolServer) SubscriptionDetailsHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error { data := &messages.UpsertSubscriptionDetails{} 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(cartId, data) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) return err } s.WriteResult(w, reply) return nil } func (s *PoolServer) CheckoutHandler(fn func(order *CheckoutOrder, w http.ResponseWriter) error) func(w http.ResponseWriter, r *http.Request) { return CookieCartIdHandler(s.ProxyHandler(func(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error { orderId := r.URL.Query().Get("order_id") if orderId == "" { order, err := s.CreateOrUpdateCheckout(r.Host, cartId) if err != nil { return err } s.ApplyCheckoutStarted(order, cartId) return fn(order, w) } order, err := s.klarnaClient.GetOrder(orderId) if err != nil { return err } return fn(order, w) })) } 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(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 } func (s *PoolServer) Serve(mux *http.ServeMux) { mux.HandleFunc("OPTIONS /", 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)) { // Configure the "http.route" for the HTTP instrumentation. handler := otelhttp.WithRouteTag(pattern, http.HandlerFunc(handlerFunc)) mux.Handle(pattern, handler) } 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("POST /cart/delivery", CookieCartIdHandler(s.ProxyHandler(s.SetDeliveryHandler))) handleFunc("DELETE /cart/delivery/{deliveryId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler))) handleFunc("PUT /cart/delivery/{deliveryId}/pickupPoint", CookieCartIdHandler(s.ProxyHandler(s.SetPickupPointHandler))) 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))) //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("GET /cart/byid/{id}/add/{sku}", CartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler))) 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("POST /cart/byid/{id}/delivery", CartIdHandler(s.ProxyHandler(s.SetDeliveryHandler))) handleFunc("DELETE /cart/byid/{id}/delivery/{deliveryId}", CartIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler))) handleFunc("PUT /cart/byid/{id}/delivery/{deliveryId}/pickupPoint", CartIdHandler(s.ProxyHandler(s.SetPickupPointHandler))) handleFunc("PUT /cart/byid/{id}/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler))) handleFunc("DELETE /cart/byid/{id}/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler))) //mux.HandleFunc("GET /cart/byid/{id}/checkout", CartIdHandler(s.ProxyHandler(s.HandleCheckout))) //mux.HandleFunc("GET /cart/byid/{id}/confirmation", CartIdHandler(s.ProxyHandler(s.HandleConfirmation))) return mux }