package main import ( "bytes" "context" "encoding/json" "fmt" "log" "net/http" "os" "time" "git.k6n.net/go-cart-actor/pkg/actor" "git.k6n.net/go-cart-actor/pkg/cart" "git.k6n.net/go-cart-actor/pkg/checkout" messages "git.k6n.net/go-cart-actor/proto/checkout" adyen "github.com/adyen/adyen-go-api-library/v21/src/adyen" "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" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/trace" ) func getOriginalHost(r *http.Request) string { proxyHost := r.Header.Get("X-Forwarded-Host") if proxyHost != "" { return proxyHost } return r.Host } func getClientIp(r *http.Request) string { ip := r.Header.Get("X-Forwarded-For") if ip == "" { ip = r.RemoteAddr } return ip } 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" } var ( grainMutations = promauto.NewCounter(prometheus.CounterOpts{ Name: "checkout_grain_mutations_total", Help: "The total number of mutations", }) grainLookups = promauto.NewCounter(prometheus.CounterOpts{ Name: "checkout_grain_lookups_total", Help: "The total number of lookups", }) ) type CheckoutPoolServer struct { actor.GrainPool[*checkout.CheckoutGrain] pod_name string klarnaClient *KlarnaClient adyenClient *adyen.APIClient cartClient *CartClient } func NewCheckoutPoolServer(pool actor.GrainPool[*checkout.CheckoutGrain], pod_name string, klarnaClient *KlarnaClient, cartClient *CartClient, adyenClient *adyen.APIClient) *CheckoutPoolServer { srv := &CheckoutPoolServer{ GrainPool: pool, pod_name: pod_name, klarnaClient: klarnaClient, cartClient: cartClient, adyenClient: adyenClient, } return srv } func (s *CheckoutPoolServer) ApplyLocal(ctx context.Context, id checkout.CheckoutId, mutation ...proto.Message) (*actor.MutationResult[*checkout.CheckoutGrain], error) { return s.Apply(ctx, uint64(id), mutation...) } func (s *CheckoutPoolServer) GetCheckoutHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error { grain, err := s.Get(r.Context(), uint64(id)) if err != nil { return err } return s.WriteResult(w, grain) } func (s *CheckoutPoolServer) 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 *CheckoutPoolServer) CreateOrUpdateCheckout(r *http.Request, id checkout.CheckoutId) (*CheckoutOrder, error) { // Get cart state from cart service cartGrain, err := s.Get(r.Context(), uint64(id)) if err != nil { return nil, err } meta := GetCheckoutMetaFromRequest(r) payload, _, err := BuildCheckoutOrderPayload(cartGrain, meta) if err != nil { return nil, err } grain, err := s.Get(r.Context(), uint64(id)) if err != nil { return nil, err } if grain.OrderId != nil { return s.klarnaClient.UpdateOrder(r.Context(), *grain.OrderId, bytes.NewReader(payload)) } else { return s.klarnaClient.CreateOrder(r.Context(), bytes.NewReader(payload)) } } func (s *CheckoutPoolServer) ApplyKlarnaPaymentStarted(ctx context.Context, klarnaOrder *CheckoutOrder, id checkout.CheckoutId) (*actor.MutationResult[*checkout.CheckoutGrain], error) { method := "checkout" return s.ApplyLocal(ctx, id, &messages.PaymentStarted{ PaymentId: klarnaOrder.ID, Amount: int64(klarnaOrder.OrderAmount), Currency: klarnaOrder.PurchaseCurrency, Provider: "klarna", Method: &method, StartedAt: timestamppb.New(time.Now()), }) } func (s *CheckoutPoolServer) CheckoutHandler(fn func(order *CheckoutOrder, w http.ResponseWriter) error) func(w http.ResponseWriter, r *http.Request) { return CheckoutIdHandler(s.ProxyHandler(func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error { orderId := r.URL.Query().Get("order_id") if orderId == "" { order, err := s.CreateOrUpdateCheckout(r, checkoutId) if err != nil { logger.Error("unable to create klarna session", "error", err) return err } s.ApplyKlarnaPaymentStarted(r.Context(), order, checkoutId) return fn(order, w) } order, err := s.klarnaClient.GetOrder(r.Context(), orderId) if err != nil { return err } return fn(order, w) })) } func (s *CheckoutPoolServer) getCartGrain(ctx context.Context, cartId cart.CartId, version int) (*cart.CartGrain, error) { // Call cart service to get grain url := fmt.Sprintf("%s/internal/cart/%s/%d", s.cartClient.baseUrl, cartId.String(), version) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } resp, err := s.cartClient.httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to get cart: %s", resp.Status) } var grain cart.CartGrain err = json.NewDecoder(resp.Body).Decode(&grain) return &grain, err } func CheckoutIdHandler(fn func(checkoutId checkout.CheckoutId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { var id checkout.CheckoutId raw := r.PathValue("id") if raw == "" { id = checkout.CheckoutId(cart.MustNewCartId()) w.Header().Set("Set-Checkout-Id", id.String()) } else { if parsedId, ok := cart.ParseCartId(raw); !ok { w.WriteHeader(http.StatusBadRequest) w.Write([]byte("checkout id is invalid")) return } else { id = checkout.CheckoutId(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 *CheckoutPoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error) func(checkoutId checkout.CheckoutId, w http.ResponseWriter, r *http.Request) error { return func(checkoutId checkout.CheckoutId, w http.ResponseWriter, r *http.Request) error { if ownerHost, ok := s.OwnerHost(uint64(checkoutId)); ok { ctx, span := tracer.Start(r.Context(), "proxy") defer span.End() span.SetAttributes(attribute.String("checkoutid", checkoutId.String())) hostAttr := attribute.String("other host", ownerHost.Name()) span.SetAttributes(hostAttr) logger.InfoContext(ctx, "checkout proxyed", "result", ownerHost.Name()) proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr)) handled, err := ownerHost.Proxy(uint64(checkoutId), w, r, nil) grainLookups.Inc() if err == nil && handled { return nil } } _, span := tracer.Start(r.Context(), "own") span.SetAttributes(attribute.String("checkoutid", checkoutId.String())) defer span.End() return fn(w, r, checkoutId) } } var ( tracer = otel.Tracer(name) hmacKey = os.Getenv("ADYEN_HMAC") meter = otel.Meter(name) logger = otelslog.NewLogger(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) } } func (s *CheckoutPoolServer) AdyenHookHandler(w http.ResponseWriter, r *http.Request) { // Similar to cart's, but apply to checkout and then callback to cart } func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) { 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("/adyen_hook", s.AdyenHookHandler) handleFunc("GET /checkout", s.CheckoutHandler(func(order *CheckoutOrder, w http.ResponseWriter) error { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")") w.WriteHeader(http.StatusOK) _, err := fmt.Fprintf(w, tpl, order.HTMLSnippet) return err })) handleFunc("GET /confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) { orderId := r.PathValue("order_id") order, err := s.klarnaClient.GetOrder(r.Context(), orderId) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) return } // Apply ConfirmationViewed to checkout checkoutId := checkout.CheckoutId(cart.MustNewCartId()) // Need to resolve from order s.Apply(r.Context(), uint64(checkoutId), &messages.ConfirmationViewed{}) // Callback to cart cartId := cart.CartId(checkoutId) // Assuming same s.cartClient.ApplyMutation(cartId, &messages.OrderCreated{OrderId: order.ID, Status: order.Status}) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) fmt.Fprintf(w, tpl, order.HTMLSnippet) }) }