move actor to pkg
Some checks failed
Build and Publish / Metadata (push) Has been cancelled
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled

This commit is contained in:
matst80
2025-10-13 10:29:55 +02:00
parent c6671ceef0
commit 1575b3a829
29 changed files with 584 additions and 654 deletions

View File

@@ -0,0 +1,61 @@
package main
import (
"context"
"fmt"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
type AmqpOrderHandler struct {
Url string
Connection *amqp.Connection
Channel *amqp.Channel
}
func (h *AmqpOrderHandler) Connect() error {
conn, err := amqp.Dial(h.Url)
if err != nil {
return fmt.Errorf("failed to connect to RabbitMQ: %w", err)
}
h.Connection = conn
ch, err := conn.Channel()
if err != nil {
return fmt.Errorf("failed to open a channel: %w", err)
}
h.Channel = ch
return nil
}
func (h *AmqpOrderHandler) Close() error {
if h.Channel != nil {
h.Channel.Close()
}
if h.Connection != nil {
return h.Connection.Close()
}
return nil
}
func (h *AmqpOrderHandler) OrderCompleted(body []byte) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := h.Channel.PublishWithContext(ctx,
"orders", // exchange
"new", // routing key
false, // mandatory
false, // immediate
amqp.Publishing{
ContentType: "application/json",
Body: body,
})
if err != nil {
return fmt.Errorf("failed to publish a message: %w", err)
}
return nil
}

263
cmd/cart/cart-grain.go Normal file
View File

@@ -0,0 +1,263 @@
package main
import (
"encoding/json"
"fmt"
"slices"
"sync"
"time"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// Legacy padded [16]byte CartId and its helper methods removed.
// Unified CartId (uint64 with base62 string form) now defined in cart_id.go.
type StockStatus int
const (
OutOfStock StockStatus = 0
LowStock StockStatus = 1
InStock StockStatus = 2
)
type CartItem struct {
Id int `json:"id"`
ItemId int `json:"itemId,omitempty"`
ParentId int `json:"parentId,omitempty"`
Sku string `json:"sku"`
Name string `json:"name"`
Price int64 `json:"price"`
TotalPrice int64 `json:"totalPrice"`
TotalTax int64 `json:"totalTax"`
OrgPrice int64 `json:"orgPrice"`
Stock StockStatus `json:"stock"`
Quantity int `json:"qty"`
Tax int `json:"tax"`
TaxRate int `json:"taxRate"`
Brand string `json:"brand,omitempty"`
Category string `json:"category,omitempty"`
Category2 string `json:"category2,omitempty"`
Category3 string `json:"category3,omitempty"`
Category4 string `json:"category4,omitempty"`
Category5 string `json:"category5,omitempty"`
Disclaimer string `json:"disclaimer,omitempty"`
SellerId string `json:"sellerId,omitempty"`
SellerName string `json:"sellerName,omitempty"`
ArticleType string `json:"type,omitempty"`
Image string `json:"image,omitempty"`
Outlet *string `json:"outlet,omitempty"`
StoreId *string `json:"storeId,omitempty"`
}
type CartDelivery struct {
Id int `json:"id"`
Provider string `json:"provider"`
Price int64 `json:"price"`
Items []int `json:"items"`
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
}
type CartGrain struct {
mu sync.RWMutex
lastItemId int
lastDeliveryId int
lastAccess time.Time
lastChange time.Time // unix seconds of last successful mutation (replay sets from event ts)
Id CartId `json:"id"`
Items []*CartItem `json:"items"`
TotalPrice int64 `json:"totalPrice"`
TotalTax int64 `json:"totalTax"`
TotalDiscount int64 `json:"totalDiscount"`
Deliveries []*CartDelivery `json:"deliveries,omitempty"`
Processing bool `json:"processing"`
PaymentInProgress bool `json:"paymentInProgress"`
OrderReference string `json:"orderReference,omitempty"`
PaymentStatus string `json:"paymentStatus,omitempty"`
}
func (c *CartGrain) GetId() uint64 {
return uint64(c.Id)
}
func (c *CartGrain) GetLastChange() time.Time {
return c.lastChange
}
func (c *CartGrain) GetLastAccess() time.Time {
return c.lastAccess
}
func (c *CartGrain) GetCurrentState() (*CartGrain, error) {
c.lastAccess = time.Now()
return c, nil
}
func getInt(data float64, ok bool) (int, error) {
if !ok {
return 0, fmt.Errorf("invalid type")
}
return int(data), nil
}
func getItemData(sku string, qty int, country string) (*messages.AddItem, error) {
item, err := FetchItem(sku, country)
if err != nil {
return nil, err
}
orgPrice, _ := getInt(item.GetNumberFieldValue(5)) // getInt(item.Fields[5])
price, priceErr := getInt(item.GetNumberFieldValue(4)) //Fields[4]
if priceErr != nil {
return nil, fmt.Errorf("invalid price")
}
stock := InStock
item.HasStock()
stockValue, ok := item.GetNumberFieldValue(3)
if !ok || stockValue == 0 {
stock = OutOfStock
} else {
if stockValue < 5 {
stock = LowStock
}
}
articleType, _ := item.GetStringFieldValue(1) //.Fields[1].(string)
outletGrade, ok := item.GetStringFieldValue(20) //.Fields[20].(string)
var outlet *string
if ok {
outlet = &outletGrade
}
sellerId, _ := item.GetStringFieldValue(24) // .Fields[24].(string)
sellerName, _ := item.GetStringFieldValue(9) // .Fields[9].(string)
brand, _ := item.GetStringFieldValue(2) //.Fields[2].(string)
category, _ := item.GetStringFieldValue(10) //.Fields[10].(string)
category2, _ := item.GetStringFieldValue(11) //.Fields[11].(string)
category3, _ := item.GetStringFieldValue(12) //.Fields[12].(string)
category4, _ := item.GetStringFieldValue(13) //Fields[13].(string)
category5, _ := item.GetStringFieldValue(14) //.Fields[14].(string)
return &messages.AddItem{
ItemId: int64(item.Id),
Quantity: int32(qty),
Price: int64(price),
OrgPrice: int64(orgPrice),
Sku: sku,
Name: item.Title,
Image: item.Img,
Stock: int32(stock),
Brand: brand,
Category: category,
Category2: category2,
Category3: category3,
Category4: category4,
Category5: category5,
Tax: 2500,
SellerId: sellerId,
SellerName: sellerName,
ArticleType: articleType,
Disclaimer: item.Disclaimer,
Country: country,
Outlet: outlet,
}, nil
}
func (c *CartGrain) AddItem(sku string, qty int, country string, storeId *string) (*CartGrain, error) {
cartItem, err := getItemData(sku, qty, country)
if err != nil {
return nil, err
}
cartItem.StoreId = storeId
return c.Apply(cartItem, false)
}
func (c *CartGrain) GetState() ([]byte, error) {
return json.Marshal(c)
}
func (c *CartGrain) ItemsWithDelivery() []int {
ret := make([]int, 0, len(c.Items))
for _, item := range c.Items {
for _, delivery := range c.Deliveries {
for _, id := range delivery.Items {
if item.Id == id {
ret = append(ret, id)
}
}
}
}
return ret
}
func (c *CartGrain) ItemsWithoutDelivery() []int {
ret := make([]int, 0, len(c.Items))
hasDelivery := c.ItemsWithDelivery()
for _, item := range c.Items {
found := slices.Contains(hasDelivery, item.Id)
if !found {
ret = append(ret, item.Id)
}
}
return ret
}
func (c *CartGrain) FindItemWithSku(sku string) (*CartItem, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
for _, item := range c.Items {
if item.Sku == sku {
return item, true
}
}
return nil, false
}
func GetTaxAmount(total int64, tax int) int64 {
taxD := 10000 / float64(tax)
return int64(float64(total) / float64((1 + taxD)))
}
func (c *CartGrain) Apply(content interface{}, isReplay bool) (*CartGrain, error) {
updated, err := ApplyRegistered(c, content)
if err != nil {
if err == ErrMutationNotRegistered {
return nil, fmt.Errorf("unsupported mutation type %T (not registered)", content)
}
return nil, err
}
// Sliding TTL: update lastChange only for non-replay successful mutations.
if updated != nil && !isReplay {
c.lastChange = time.Now()
c.lastAccess = time.Now()
go AppendCartEvent(c.Id, content)
}
return updated, nil
}
func (c *CartGrain) UpdateTotals() {
c.TotalPrice = 0
c.TotalTax = 0
c.TotalDiscount = 0
for _, item := range c.Items {
rowTotal := item.Price * int64(item.Quantity)
rowTax := int64(item.Tax) * int64(item.Quantity)
item.TotalPrice = rowTotal
item.TotalTax = rowTax
c.TotalPrice += rowTotal
c.TotalTax += rowTax
itemDiff := max(0, item.OrgPrice-item.Price)
c.TotalDiscount += itemDiff * int64(item.Quantity)
}
for _, delivery := range c.Deliveries {
c.TotalPrice += delivery.Price
c.TotalTax += GetTaxAmount(delivery.Price, 2500)
}
}

159
cmd/cart/cart_id.go Normal file
View File

@@ -0,0 +1,159 @@
package main
import (
"crypto/rand"
"encoding/json"
"fmt"
)
// cart_id.go
//
// Breaking change:
// Unified cart identifier as a raw 64-bit unsigned integer (type CartId uint64).
// External textual representation: base62 (0-9 A-Z a-z), shortest possible
// encoding for 64 bits (max 11 characters, since 62^11 > 2^64).
//
// Rationale:
// - Replaces legacy fixed [16]byte padded string and transitional CartID wrapper.
// - Provides compact, URL/cookie-friendly identifiers.
// - O(1) hashing and minimal memory footprint.
// - 64 bits of crypto randomness => negligible collision probability at realistic scale.
//
// Public API:
// type CartId uint64
// func NewCartId() (CartId, error)
// func MustNewCartId() CartId
// func ParseCartId(string) (CartId, bool)
// func MustParseCartId(string) CartId
// (CartId).String() string
// (CartId).MarshalJSON() / UnmarshalJSON()
//
// NOTE:
// All legacy helpers (UpgradeLegacyCartId, Fallback hashing, Canonicalize variants,
// CartIDToLegacy, LegacyToCartID) have been removed as part of the breaking change.
//
// ---------------------------------------------------------------------------
type CartId uint64
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
// Reverse lookup (0xFF marks invalid)
var base62Rev [256]byte
func init() {
for i := range base62Rev {
base62Rev[i] = 0xFF
}
for i := 0; i < len(base62Alphabet); i++ {
base62Rev[base62Alphabet[i]] = byte(i)
}
}
// String returns the canonical base62 encoding of the 64-bit id.
func (id CartId) String() string {
return encodeBase62(uint64(id))
}
// MarshalJSON encodes the cart id as a JSON string.
func (id CartId) MarshalJSON() ([]byte, error) {
return json.Marshal(id.String())
}
// UnmarshalJSON decodes a cart id from a JSON string containing base62 text.
func (id *CartId) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
parsed, ok := ParseCartId(s)
if !ok {
return fmt.Errorf("invalid cart id: %q", s)
}
*id = parsed
return nil
}
// NewCartId generates a new cryptographically random non-zero 64-bit id.
func NewCartId() (CartId, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return 0, fmt.Errorf("NewCartId: %w", err)
}
u := (uint64(b[0]) << 56) |
(uint64(b[1]) << 48) |
(uint64(b[2]) << 40) |
(uint64(b[3]) << 32) |
(uint64(b[4]) << 24) |
(uint64(b[5]) << 16) |
(uint64(b[6]) << 8) |
uint64(b[7])
if u == 0 {
// Extremely unlikely; regenerate once to avoid "0" identifier if desired.
return NewCartId()
}
return CartId(u), nil
}
// MustNewCartId panics if generation fails.
func MustNewCartId() CartId {
id, err := NewCartId()
if err != nil {
panic(err)
}
return id
}
// ParseCartId parses a base62 string into a CartId.
// Returns (0,false) for invalid input.
func ParseCartId(s string) (CartId, bool) {
// Accept length 1..11 (11 sufficient for 64 bits). Reject >11 immediately.
// Provide a slightly looser upper bound (<=16) only if you anticipate future
// extensions; here we stay strict.
if len(s) == 0 || len(s) > 11 {
return 0, false
}
u, ok := decodeBase62(s)
if !ok {
return 0, false
}
return CartId(u), true
}
// MustParseCartId panics on invalid base62 input.
func MustParseCartId(s string) CartId {
id, ok := ParseCartId(s)
if !ok {
panic(fmt.Sprintf("invalid cart id: %q", s))
}
return id
}
// encodeBase62 converts a uint64 to base62 (shortest form).
func encodeBase62(u uint64) string {
if u == 0 {
return "0"
}
var buf [11]byte
i := len(buf)
for u > 0 {
i--
buf[i] = base62Alphabet[u%62]
u /= 62
}
return string(buf[i:])
}
// decodeBase62 converts base62 text to uint64.
func decodeBase62(s string) (uint64, bool) {
var v uint64
for i := 0; i < len(s); i++ {
c := s[i]
d := base62Rev[c]
if d == 0xFF {
return 0, false
}
v = v*62 + uint64(d)
}
return v, true
}

185
cmd/cart/cart_id_test.go Normal file
View File

@@ -0,0 +1,185 @@
package main
import (
"encoding/json"
"fmt"
"testing"
)
// TestNewCartIdUniqueness generates many ids and checks for collisions.
func TestNewCartIdUniqueness(t *testing.T) {
const n = 20000
seen := make(map[string]struct{}, n)
for i := 0; i < n; i++ {
id, err := NewCartId()
if err != nil {
t.Fatalf("NewCartId error: %v", err)
}
s := id.String()
if _, exists := seen[s]; exists {
t.Fatalf("duplicate id encountered: %s", s)
}
seen[s] = struct{}{}
if s == "" {
t.Fatalf("empty string representation for id %d", id)
}
if len(s) > 11 {
t.Fatalf("encoded id length exceeds 11 chars: %s (%d)", s, len(s))
}
if id == 0 {
// We force regeneration on zero, extremely unlikely but test guards intent.
t.Fatalf("zero id generated (should be regenerated)")
}
}
}
// TestParseCartIdRoundTrip ensures parse -> string -> parse is stable.
func TestParseCartIdRoundTrip(t *testing.T) {
id := MustNewCartId()
txt := id.String()
parsed, ok := ParseCartId(txt)
if !ok {
t.Fatalf("ParseCartId failed for valid text %q", txt)
}
if parsed != id {
t.Fatalf("round trip mismatch: original=%d parsed=%d txt=%s", id, parsed, txt)
}
}
// TestParseCartIdInvalid covers invalid inputs.
func TestParseCartIdInvalid(t *testing.T) {
invalid := []string{
"", // empty
" ", // space
"01234567890abc", // >11 chars
"!!!!", // invalid chars
"-underscore-", // invalid chars
"abc_def", // underscore invalid for base62
"0123456789ABCD", // 14 chars
}
for _, s := range invalid {
if _, ok := ParseCartId(s); ok {
t.Fatalf("expected parse failure for %q", s)
}
}
}
// TestMustParseCartIdPanics verifies panic behavior for invalid input.
func TestMustParseCartIdPanics(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatalf("expected panic for invalid MustParseCartId input")
}
}()
_ = MustParseCartId("not*base62")
}
// TestJSONMarshalUnmarshalCartId verifies JSON round trip.
func TestJSONMarshalUnmarshalCartId(t *testing.T) {
id := MustNewCartId()
data, err := json.Marshal(struct {
Cart CartId `json:"cart"`
}{Cart: id})
if err != nil {
t.Fatalf("marshal error: %v", err)
}
var out struct {
Cart CartId `json:"cart"`
}
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if out.Cart != id {
t.Fatalf("JSON round trip mismatch: have %d got %d", id, out.Cart)
}
}
// TestBase62LengthBound checks worst-case length (near max uint64).
func TestBase62LengthBound(t *testing.T) {
// Largest uint64
const maxU64 = ^uint64(0)
s := encodeBase62(maxU64)
if len(s) > 11 {
t.Fatalf("max uint64 encoded length > 11: %d (%s)", len(s), s)
}
dec, ok := decodeBase62(s)
if !ok || dec != maxU64 {
t.Fatalf("decode failed for max uint64: ok=%v dec=%d want=%d", ok, dec, maxU64)
}
}
// TestZeroEncoding ensures zero value encodes to "0" and parses back.
func TestZeroEncoding(t *testing.T) {
if s := encodeBase62(0); s != "0" {
t.Fatalf("encodeBase62(0) expected '0', got %q", s)
}
v, ok := decodeBase62("0")
if !ok || v != 0 {
t.Fatalf("decodeBase62('0') failed: ok=%v v=%d", ok, v)
}
if _, ok := ParseCartId("0"); !ok {
t.Fatalf("ParseCartId(\"0\") should succeed")
}
}
// TestSequentialParse ensures sequentially generated ids parse correctly.
func TestSequentialParse(t *testing.T) {
for i := 0; i < 1000; i++ {
id := MustNewCartId()
txt := id.String()
parsed, ok := ParseCartId(txt)
if !ok || parsed != id {
t.Fatalf("sequential parse mismatch: idx=%d orig=%d parsed=%d txt=%s", i, id, parsed, txt)
}
}
}
// BenchmarkNewCartId measures generation performance.
func BenchmarkNewCartId(b *testing.B) {
for i := 0; i < b.N; i++ {
if _, err := NewCartId(); err != nil {
b.Fatalf("NewCartId error: %v", err)
}
}
}
// BenchmarkEncodeBase62 measures encoding performance.
func BenchmarkEncodeBase62(b *testing.B) {
// Precompute sample values
samples := make([]uint64, 1024)
for i := range samples {
// Spread bits without crypto randomness overhead
samples[i] = (uint64(i) << 53) ^ (uint64(i) * 0x9E3779B185EBCA87)
}
b.ResetTimer()
var sink string
for i := 0; i < b.N; i++ {
sink = encodeBase62(samples[i%len(samples)])
}
_ = sink
}
// BenchmarkDecodeBase62 measures decoding performance.
func BenchmarkDecodeBase62(b *testing.B) {
encoded := make([]string, 1024)
for i := range encoded {
encoded[i] = encodeBase62((uint64(i) << 32) | uint64(i))
}
b.ResetTimer()
var sum uint64
for i := 0; i < b.N; i++ {
v, ok := decodeBase62(encoded[i%len(encoded)])
if !ok {
b.Fatalf("decode failure for %s", encoded[i%len(encoded)])
}
sum ^= v
}
_ = sum
}
// ExampleCartIdString documents usage of CartId string form.
func ExampleCartId_string() {
id := MustNewCartId()
fmt.Println(len(id.String()) <= 11) // outputs true
// Output: true
}

View File

@@ -0,0 +1,119 @@
package main
import (
"encoding/json"
"fmt"
)
// CheckoutMeta carries the external / URL metadata required to build a
// Klarna CheckoutOrder from a CartGrain snapshot. It deliberately excludes
// any Klarna-specific response fields (HTML snippet, client token, etc.).
type CheckoutMeta struct {
Terms string
Checkout string
Confirmation string
Validation string
Push string
Country string
Currency string // optional override (defaults to "SEK" if empty)
Locale string // optional override (defaults to "sv-se" if empty)
}
// BuildCheckoutOrderPayload converts the current cart grain + meta information
// into a CheckoutOrder domain struct and returns its JSON-serialized payload
// (to send to Klarna) alongside the structured CheckoutOrder object.
//
// This function is PURE: it does not perform any network I/O or mutate the
// grain. The caller is responsible for:
//
// 1. Choosing whether to create or update the Klarna order.
// 2. Invoking KlarnaClient.CreateOrder / UpdateOrder with the returned payload.
// 3. Applying an InitializeCheckout mutation (or equivalent) with the
// resulting Klarna order id + status.
//
// If you later need to support different tax rates per line, you can extend
// CartItem / Delivery to expose that data and propagate it here.
func BuildCheckoutOrderPayload(grain *CartGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
if grain == nil {
return nil, nil, fmt.Errorf("nil grain")
}
if meta == nil {
return nil, nil, fmt.Errorf("nil checkout meta")
}
currency := meta.Currency
if currency == "" {
currency = "SEK"
}
locale := meta.Locale
if locale == "" {
locale = "sv-se"
}
country := meta.Country
if country == "" {
country = "SE" // sensible default; adjust if multi-country support changes
}
lines := make([]*Line, 0, len(grain.Items)+len(grain.Deliveries))
// Item lines
for _, it := range grain.Items {
if it == nil {
continue
}
lines = append(lines, &Line{
Type: "physical",
Reference: it.Sku,
Name: it.Name,
Quantity: it.Quantity,
UnitPrice: int(it.Price),
TaxRate: 2500, // TODO: derive if variable tax rates are introduced
QuantityUnit: "st",
TotalAmount: int(it.TotalPrice),
TotalTaxAmount: int(it.TotalTax),
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", it.Image),
})
}
// Delivery lines
for _, d := range grain.Deliveries {
if d == nil || d.Price <= 0 {
continue
}
lines = append(lines, &Line{
Type: "shipping_fee",
Reference: d.Provider,
Name: "Delivery",
Quantity: 1,
UnitPrice: int(d.Price),
TaxRate: 2500,
QuantityUnit: "st",
TotalAmount: int(d.Price),
TotalTaxAmount: int(GetTaxAmount(d.Price, 2500)),
})
}
order := &CheckoutOrder{
PurchaseCountry: country,
PurchaseCurrency: currency,
Locale: locale,
OrderAmount: int(grain.TotalPrice),
OrderTaxAmount: int(grain.TotalTax),
OrderLines: lines,
MerchantReference1: grain.Id.String(),
MerchantURLS: &CheckoutMerchantURLS{
Terms: meta.Terms,
Checkout: meta.Checkout,
Confirmation: meta.Confirmation,
Validation: meta.Validation,
Push: meta.Push,
},
}
payload, err := json.Marshal(order)
if err != nil {
return nil, nil, fmt.Errorf("marshal checkout order: %w", err)
}
return payload, order, nil
}

73
cmd/cart/disk-storage.go Normal file
View File

@@ -0,0 +1,73 @@
package main
import (
"encoding/gob"
"time"
)
func init() {
gob.Register(map[uint64]int64{})
}
type DiskStorage struct {
stateFile string
lastSave time.Time
LastSaves map[uint64]time.Time
}
func NewDiskStorage(stateFile string) (*DiskStorage, error) {
ret := &DiskStorage{
stateFile: stateFile,
LastSaves: make(map[uint64]time.Time),
}
//err := ret.loadState()
return ret, nil
}
// func saveMessages(_ interface{}, _ CartId) error {
// // No-op: legacy event log persistence removed in oneof refactor.
// return nil
// }
// func getCartPath(id string) string {
// return fmt.Sprintf("data/%s.prot", id)
// }
// func loadMessages(_ Grain, _ CartId) error {
// // No-op: legacy replay removed in oneof refactor.
// return nil
// }
// func (s *DiskStorage) saveState() error {
// tmpFile := s.stateFile + "_tmp"
// file, err := os.Create(tmpFile)
// if err != nil {
// return err
// }
// defer file.Close()
// err = gob.NewEncoder(file).Encode(s.LastSaves)
// if err != nil {
// return err
// }
// os.Remove(s.stateFile + ".bak")
// os.Rename(s.stateFile, s.stateFile+".bak")
// return os.Rename(tmpFile, s.stateFile)
// }
// func (s *DiskStorage) loadState() error {
// file, err := os.Open(s.stateFile)
// if err != nil {
// return err
// }
// defer file.Close()
// return gob.NewDecoder(file).Decode(&s.LastSaves)
// }
func (s *DiskStorage) Store(id CartId, _ *CartGrain) error {
// With the removal of the legacy message log, we only update the timestamp.
ts := time.Now()
s.LastSaves[uint64(id)] = ts
s.lastSave = ts
return nil
}

288
cmd/cart/event_log.go Normal file
View File

@@ -0,0 +1,288 @@
package main
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"sync"
"time"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
/*
event_log.go
Append-only cart event log (per cart id) with replay + metrics.
Rationale:
- Enables recovery of in-memory cart state after process restarts or TTL eviction.
- Provides a chronological mutation trail for auditing / debugging.
- Avoids write amplification of full snapshots on every mutation.
Format:
One JSON object per line:
{
"ts": 1700000000,
"type": "AddRequest",
"payload": { ... mutation fields ... }
}
Concurrency:
- Appends: synchronized per-cart via an in-process mutex map to avoid partial writes.
- Replay: sequential read of entire file; mutations applied in order.
Usage Integration (to be wired by caller):
1. After successful mutation application (non-replay), invoke:
AppendCartEvent(grain.GetId(), mutation)
2. During grain spawn, call:
ReplayCartEvents(grain, grain.GetId())
BEFORE serving requests, so state is reconstructed.
Metrics:
- cart_event_log_appends_total
- cart_event_log_replay_total
- cart_event_log_replay_failures_total
- cart_event_log_bytes_written_total
- cart_event_log_files_existing (gauge)
- cart_event_log_last_append_unix (gauge)
- cart_event_log_replay_duration_seconds (histogram)
Rotation / Compaction:
- Not implemented. If needed, implement size checks and snapshot+truncate later.
Caveats:
- Mutation schema changes may break replay unless backward-compatible.
- Missing / unknown event types are skipped (metric incremented).
- If a mutation fails during replay, replay continues (logged + metric).
*/
var (
eventLogDir = "data"
eventAppendsTotal = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_event_log_appends_total",
Help: "Total number of cart mutation events appended to event logs.",
})
eventReplayTotal = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_event_log_replay_total",
Help: "Total number of successful event log replays (per cart).",
})
eventReplayFailuresTotal = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_event_log_replay_failures_total",
Help: "Total number of failed event log replay operations.",
})
eventBytesWrittenTotal = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_event_log_bytes_written_total",
Help: "Cumulative number of bytes written to all cart event logs.",
})
eventFilesExisting = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_event_log_files_existing",
Help: "Number of cart event log files currently existing on disk.",
})
eventLastAppendUnix = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_event_log_last_append_unix",
Help: "Unix timestamp of the last append to any cart event log.",
})
eventReplayDuration = promauto.NewHistogram(prometheus.HistogramOpts{
Name: "cart_event_log_replay_duration_seconds",
Help: "Duration of replay operations per cart in seconds.",
Buckets: prometheus.DefBuckets,
})
eventUnknownTypesTotal = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_event_log_unknown_types_total",
Help: "Total number of unknown event types encountered during replay (skipped).",
})
eventMutationErrorsTotal = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_event_log_mutation_errors_total",
Help: "Total number of errors applying mutation events during replay.",
})
)
type cartEventRecord struct {
Timestamp int64 `json:"ts"`
Type string `json:"type"`
Payload interface{} `json:"payload"`
}
// registry of supported mutation payload type constructors
var eventTypeFactories = map[string]func() interface{}{
"AddRequest": func() interface{} { return &messages.AddRequest{} },
"AddItem": func() interface{} { return &messages.AddItem{} },
"RemoveItem": func() interface{} { return &messages.RemoveItem{} },
"RemoveDelivery": func() interface{} { return &messages.RemoveDelivery{} },
"ChangeQuantity": func() interface{} { return &messages.ChangeQuantity{} },
"SetDelivery": func() interface{} { return &messages.SetDelivery{} },
"SetPickupPoint": func() interface{} { return &messages.SetPickupPoint{} },
"SetCartRequest": func() interface{} { return &messages.SetCartRequest{} },
"OrderCreated": func() interface{} { return &messages.OrderCreated{} },
"InitializeCheckout": func() interface{} { return &messages.InitializeCheckout{} },
}
// Per-cart mutexes to serialize append operations (avoid partial overlapping writes)
var (
eventLogMu sync.Map // map[string]*sync.Mutex
)
// getCartEventMutex returns a mutex for a specific cart id string.
func getCartEventMutex(id string) *sync.Mutex {
if v, ok := eventLogMu.Load(id); ok {
return v.(*sync.Mutex)
}
m := &sync.Mutex{}
actual, _ := eventLogMu.LoadOrStore(id, m)
return actual.(*sync.Mutex)
}
// EventLogPath returns the path to the cart's event log file.
func EventLogPath(id CartId) string {
return filepath.Join(eventLogDir, fmt.Sprintf("%s.events.log", id.String()))
}
// EnsureEventLogDirectory ensures base directory exists and updates gauge.
func EnsureEventLogDirectory() error {
if _, err := os.Stat(eventLogDir); errors.Is(err, os.ErrNotExist) {
if err2 := os.MkdirAll(eventLogDir, 0755); err2 != nil {
return err2
}
}
// Update files existing gauge (approximate; counts matching *.events.log)
pattern := filepath.Join(eventLogDir, "*.events.log")
matches, _ := filepath.Glob(pattern)
eventFilesExisting.Set(float64(len(matches)))
return nil
}
// AppendCartEvent appends a mutation event to the cart's log (JSON line).
func AppendCartEvent(id CartId, mutation interface{}) error {
if mutation == nil {
return errors.New("nil mutation cannot be logged")
}
if err := EnsureEventLogDirectory(); err != nil {
return err
}
typ := mutationTypeName(mutation)
rec := cartEventRecord{
Timestamp: time.Now().Unix(),
Type: typ,
Payload: mutation,
}
lineBytes, err := json.Marshal(rec)
if err != nil {
return fmt.Errorf("marshal event: %w", err)
}
lineBytes = append(lineBytes, '\n')
path := EventLogPath(id)
mtx := getCartEventMutex(id.String())
mtx.Lock()
defer mtx.Unlock()
fh, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("open event log: %w", err)
}
defer fh.Close()
n, werr := fh.Write(lineBytes)
if werr != nil {
return fmt.Errorf("write event log: %w", werr)
}
eventAppendsTotal.Inc()
eventBytesWrittenTotal.Add(float64(n))
eventLastAppendUnix.Set(float64(rec.Timestamp))
return nil
}
// ReplayCartEvents replays an existing cart's event log into the provided grain.
// It applies mutation payloads in order, skipping unknown types.
func ReplayCartEvents(grain *CartGrain, id CartId) error {
start := time.Now()
path := EventLogPath(id)
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
// No log -> nothing to replay
return nil
}
fh, err := os.Open(path)
if err != nil {
eventReplayFailuresTotal.Inc()
return fmt.Errorf("open replay file: %w", err)
}
defer fh.Close()
scanner := bufio.NewScanner(fh)
// Increase buffer in case of large payloads
const maxLine = 256 * 1024
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, maxLine)
for scanner.Scan() {
line := scanner.Bytes()
var raw struct {
Timestamp time.Time `json:"ts"`
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
if err := json.Unmarshal(line, &raw); err != nil {
eventReplayFailuresTotal.Inc()
continue // skip malformed line
}
factory, ok := eventTypeFactories[raw.Type]
if !ok {
eventUnknownTypesTotal.Inc()
continue // skip unknown mutation type
}
instance := factory()
if err := json.Unmarshal(raw.Payload, instance); err != nil {
eventMutationErrorsTotal.Inc()
continue
}
// Apply mutation directly using internal registration (bypass AppendCartEvent recursion).
if _, applyErr := ApplyRegistered(grain, instance); applyErr != nil {
eventMutationErrorsTotal.Inc()
continue
} else {
// Update lastChange to the timestamp of this event (sliding inactivity window support).
grain.lastChange = raw.Timestamp
}
}
if serr := scanner.Err(); serr != nil {
eventReplayFailuresTotal.Inc()
return fmt.Errorf("scanner error: %w", serr)
}
eventReplayTotal.Inc()
eventReplayDuration.Observe(time.Since(start).Seconds())
return nil
}
// mutationTypeName returns the short struct name for a mutation (pointer aware).
func mutationTypeName(v interface{}) string {
if v == nil {
return "nil"
}
t := reflect.TypeOf(v)
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
return t.Name()
}
/*
Future enhancements:
- Compression: gzip large (> N events) logs to reduce disk usage.
- Compaction: periodic snapshot + truncate old events to bound replay latency.
- Checkpoint events: inject cart state snapshots every M mutations.
- Integrity: add checksum per line for corruption detection.
- Multi-writer safety across processes (currently only safe within one process).
*/

128
cmd/cart/klarna-client.go Normal file
View File

@@ -0,0 +1,128 @@
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"github.com/google/uuid"
)
type KlarnaClient struct {
Url string
UserName string
Password string
client *http.Client
}
func NewKlarnaClient(url, userName, password string) *KlarnaClient {
return &KlarnaClient{
Url: url,
UserName: userName,
Password: password,
client: &http.Client{},
}
}
const (
KlarnaPlaygroundUrl = "https://api.playground.klarna.com"
)
func (k *KlarnaClient) GetOrder(orderId string) (*CheckoutOrder, error) {
req, err := http.NewRequest("GET", k.Url+"/checkout/v3/orders/"+orderId, nil)
if err != nil {
return nil, err
}
req.Header.Add("Content-Type", "application/json")
req.SetBasicAuth(k.UserName, k.Password)
res, err := k.client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
return k.getOrderResponse(res)
}
func (k *KlarnaClient) getOrderResponse(res *http.Response) (*CheckoutOrder, error) {
var err error
var klarnaOrderResponse CheckoutOrder
if res.StatusCode >= 200 && res.StatusCode <= 299 {
err = json.NewDecoder(res.Body).Decode(&klarnaOrderResponse)
if err != nil {
return nil, err
}
return &klarnaOrderResponse, nil
}
body, err := io.ReadAll(res.Body)
if err == nil {
log.Println(string(body))
}
return nil, fmt.Errorf("%s", res.Status)
}
func (k *KlarnaClient) CreateOrder(reader io.Reader) (*CheckoutOrder, error) {
//bytes.NewReader(reply.Payload)
req, err := http.NewRequest("POST", k.Url+"/checkout/v3/orders", reader)
if err != nil {
return nil, err
}
req.Header.Add("Content-Type", "application/json")
req.SetBasicAuth(k.UserName, k.Password)
res, err := http.DefaultClient.Do(req)
if nil != err {
return nil, err
}
defer res.Body.Close()
return k.getOrderResponse(res)
}
func (k *KlarnaClient) UpdateOrder(orderId string, reader io.Reader) (*CheckoutOrder, error) {
//bytes.NewReader(reply.Payload)
req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkout/v3/orders/%s", k.Url, orderId), reader)
if err != nil {
return nil, err
}
req.Header.Add("Content-Type", "application/json")
req.SetBasicAuth(k.UserName, k.Password)
res, err := http.DefaultClient.Do(req)
if nil != err {
return nil, err
}
defer res.Body.Close()
return k.getOrderResponse(res)
}
func (k *KlarnaClient) AbortOrder(orderId string) error {
req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkout/v3/orders/%s/abort", k.Url, orderId), nil)
if err != nil {
return err
}
req.SetBasicAuth(k.UserName, k.Password)
_, err = http.DefaultClient.Do(req)
return err
}
// ordermanagement/v1/orders/{order_id}/acknowledge
func (k *KlarnaClient) AcknowledgeOrder(orderId string) error {
req, err := http.NewRequest("POST", fmt.Sprintf("%s/ordermanagement/v1/orders/%s/acknowledge", k.Url, orderId), nil)
if err != nil {
return err
}
id := uuid.New()
req.SetBasicAuth(k.UserName, k.Password)
req.Header.Add("Klarna-Idempotency-Key", id.String())
_, err = http.DefaultClient.Do(req)
return err
}

169
cmd/cart/klarna-types.go Normal file
View File

@@ -0,0 +1,169 @@
package main
type (
LineType string
// CheckoutOrder type is the request structure to create a new order from the Checkout API
CheckoutOrder struct {
ID string `json:"order_id,omitempty"`
PurchaseCountry string `json:"purchase_country"`
PurchaseCurrency string `json:"purchase_currency"`
Locale string `json:"locale"`
Status string `json:"status,omitempty"`
BillingAddress *Address `json:"billing_address,omitempty"`
ShippingAddress *Address `json:"shipping_address,omitempty"`
OrderAmount int `json:"order_amount"`
OrderTaxAmount int `json:"order_tax_amount"`
OrderLines []*Line `json:"order_lines"`
Customer *CheckoutCustomer `json:"customer,omitempty"`
MerchantURLS *CheckoutMerchantURLS `json:"merchant_urls"`
HTMLSnippet string `json:"html_snippet,omitempty"`
MerchantReference1 string `json:"merchant_reference1,omitempty"`
MerchantReference2 string `json:"merchant_reference2,omitempty"`
StartedAt string `json:"started_at,omitempty"`
CompletedAt string `json:"completed_at,omitempty"`
LastModifiedAt string `json:"last_modified_at,omitempty"`
Options *CheckoutOptions `json:"options,omitempty"`
Attachment *Attachment `json:"attachment,omitempty"`
ExternalPaymentMethods []*PaymentProvider `json:"external_payment_methods,omitempty"`
ExternalCheckouts []*PaymentProvider `json:"external_checkouts,omitempty"`
ShippingCountries []string `json:"shipping_countries,omitempty"`
ShippingOptions []*ShippingOption `json:"shipping_options,omitempty"`
MerchantData string `json:"merchant_data,omitempty"`
GUI *GUI `json:"gui,omitempty"`
MerchantRequested *AdditionalCheckBox `json:"merchant_requested,omitempty"`
SelectedShippingOption *ShippingOption `json:"selected_shipping_option,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
ErrorMessages []string `json:"error_messages,omitempty"`
}
// GUI type wraps the GUI options
GUI struct {
Options []string `json:"options,omitempty"`
}
// ShippingOption type is part of the CheckoutOrder structure, represent the shipping options field
ShippingOption struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Promo string `json:"promo,omitempty"`
Price int `json:"price"`
TaxAmount int `json:"tax_amount"`
TaxRate int `json:"tax_rate"`
Preselected bool `json:"preselected,omitempty"`
ShippingMethod string `json:"shipping_method,omitempty"`
}
// PaymentProvider type is part of the CheckoutOrder structure, represent the ExternalPaymentMethods and
// ExternalCheckouts field
PaymentProvider struct {
Name string `json:"name"`
RedirectURL string `json:"redirect_url"`
ImageURL string `json:"image_url,omitempty"`
Fee int `json:"fee,omitempty"`
Description string `json:"description,omitempty"`
Countries []string `json:"countries,omitempty"`
}
Attachment struct {
ContentType string `json:"content_type"`
Body string `json:"body"`
}
CheckoutOptions struct {
AcquiringChannel string `json:"acquiring_channel,omitempty"`
AllowSeparateShippingAddress bool `json:"allow_separate_shipping_address,omitempty"`
ColorButton string `json:"color_button,omitempty"`
ColorButtonText string `json:"color_button_text,omitempty"`
ColorCheckbox string `json:"color_checkbox,omitempty"`
ColorCheckboxCheckmark string `json:"color_checkbox_checkmark,omitempty"`
ColorHeader string `json:"color_header,omitempty"`
ColorLink string `json:"color_link,omitempty"`
DateOfBirthMandatory bool `json:"date_of_birth_mandatory,omitempty"`
ShippingDetails string `json:"shipping_details,omitempty"`
TitleMandatory bool `json:"title_mandatory,omitempty"`
AdditionalCheckbox *AdditionalCheckBox `json:"additional_checkbox"`
RadiusBorder string `json:"radius_border,omitempty"`
ShowSubtotalDetail bool `json:"show_subtotal_detail,omitempty"`
RequireValidateCallbackSuccess bool `json:"require_validate_callback_success,omitempty"`
AllowGlobalBillingCountries bool `json:"allow_global_billing_countries,omitempty"`
}
AdditionalCheckBox struct {
Text string `json:"text"`
Checked bool `json:"checked"`
Required bool `json:"required"`
}
CheckoutMerchantURLS struct {
// URL of merchant terms and conditions. Should be different than checkout, confirmation and push URLs.
// (max 2000 characters)
Terms string `json:"terms"`
// URL of merchant checkout page. Should be different than terms, confirmation and push URLs.
// (max 2000 characters)
Checkout string `json:"checkout"`
// URL of merchant confirmation page. Should be different than checkout and confirmation URLs.
// (max 2000 characters)
Confirmation string `json:"confirmation"`
// URL that will be requested when an order is completed. Should be different than checkout and
// confirmation URLs. (max 2000 characters)
Push string `json:"push"`
// URL that will be requested for final merchant validation. (must be https, max 2000 characters)
Validation string `json:"validation,omitempty"`
// URL for shipping option update. (must be https, max 2000 characters)
ShippingOptionUpdate string `json:"shipping_option_update,omitempty"`
// URL for shipping, tax and purchase currency updates. Will be called on address changes.
// (must be https, max 2000 characters)
AddressUpdate string `json:"address_update,omitempty"`
// URL for notifications on pending orders. (max 2000 characters)
Notification string `json:"notification,omitempty"`
// URL for shipping, tax and purchase currency updates. Will be called on purchase country changes.
// (must be https, max 2000 characters)
CountryChange string `json:"country_change,omitempty"`
}
CheckoutCustomer struct {
// DateOfBirth in string representation 2006-01-02
DateOfBirth string `json:"date_of_birth"`
}
// Address type define the address object (json serializable) being used for the API to represent billing &
// shipping addresses
Address struct {
GivenName string `json:"given_name,omitempty"`
FamilyName string `json:"family_name,omitempty"`
Email string `json:"email,omitempty"`
Title string `json:"title,omitempty"`
StreetAddress string `json:"street_address,omitempty"`
StreetAddress2 string `json:"street_address2,omitempty"`
PostalCode string `json:"postal_code,omitempty"`
City string `json:"city,omitempty"`
Region string `json:"region,omitempty"`
Phone string `json:"phone,omitempty"`
Country string `json:"country,omitempty"`
}
Line struct {
Type string `json:"type,omitempty"`
Reference string `json:"reference,omitempty"`
Name string `json:"name"`
Quantity int `json:"quantity"`
QuantityUnit string `json:"quantity_unit,omitempty"`
UnitPrice int `json:"unit_price"`
TaxRate int `json:"tax_rate"`
TotalAmount int `json:"total_amount"`
TotalDiscountAmount int `json:"total_discount_amount,omitempty"`
TotalTaxAmount int `json:"total_tax_amount"`
MerchantData string `json:"merchant_data,omitempty"`
ProductURL string `json:"product_url,omitempty"`
ImageURL string `json:"image_url,omitempty"`
}
)

413
cmd/cart/main.go Normal file
View File

@@ -0,0 +1,413 @@
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/pprof"
"os"
"os/signal"
"strings"
"syscall"
"time"
"git.tornberg.me/go-cart-actor/pkg/actor"
"git.tornberg.me/go-cart-actor/pkg/discovery"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"git.tornberg.me/go-cart-actor/pkg/proxy"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
var (
grainSpawns = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_grain_spawned_total",
Help: "The total number of spawned grains",
})
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",
})
)
func spawn(id uint64) (actor.Grain[CartGrain], error) {
grainSpawns.Inc()
ret := &CartGrain{
lastItemId: 0,
lastDeliveryId: 0,
Deliveries: []*CartDelivery{},
Id: CartId(id),
Items: []*CartItem{},
TotalPrice: 0,
}
// Set baseline lastChange at spawn; replay may update it to last event timestamp.
ret.lastChange = time.Now()
ret.lastAccess = time.Now()
// Legacy loadMessages (no-op) retained; then replay append-only event log
//_ = loadMessages(ret, id)
err := ReplayCartEvents(ret, CartId(id))
return ret, err
}
func init() {
os.Mkdir("data", 0755)
}
type App struct {
pool *actor.SimpleGrainPool[CartGrain]
storage *DiskStorage
}
var podIp = os.Getenv("POD_IP")
var name = os.Getenv("POD_NAME")
var amqpUrl = os.Getenv("AMQP_URL")
var tpl = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>s10r testing - checkout</title>
</head>
<body>
%s
</body>
</html>
`
func getCountryFromHost(host string) string {
if strings.Contains(strings.ToLower(host), "-no") {
return "no"
}
return "se"
}
func GetDiscovery() discovery.Discovery {
if podIp == "" {
return nil
}
config, kerr := rest.InClusterConfig()
if kerr != nil {
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
}
client, err := kubernetes.NewForConfig(config)
if err != nil {
log.Fatalf("Error creating client: %v\n", err)
}
return discovery.NewK8sDiscovery(client)
}
func main() {
controlPlaneConfig := actor.DefaultServerConfig()
storage, err := NewDiskStorage(fmt.Sprintf("data/s_%s.gob", name))
if err != nil {
log.Printf("Error loading state: %v\n", err)
}
pool, err := actor.NewSimpleGrainPool(2*65535, 15*time.Minute, podIp, spawn, func(host string) (actor.Host, error) {
return proxy.NewRemoteHost(host)
})
if err != nil {
log.Fatalf("Error creating cart pool: %v\n", err)
}
app := &App{
pool: pool,
storage: storage,
}
grpcSrv, err := actor.NewControlServer[*CartGrain](controlPlaneConfig, pool)
if err != nil {
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
}
defer grpcSrv.GracefulStop()
go func(hw discovery.Discovery) {
if hw == nil {
log.Print("No discovery service available")
return
}
ch, err := hw.Watch()
if err != nil {
log.Printf("Discovery error: %v", err)
return
}
for evt := range ch {
if evt.Host == "" {
continue
}
switch evt.Type {
case watch.Deleted:
if pool.IsKnown(evt.Host) {
pool.RemoveHost(evt.Host)
}
default:
if !pool.IsKnown(evt.Host) {
log.Printf("Discovered host %s", evt.Host)
pool.AddRemote(evt.Host)
}
}
}
}(GetDiscovery())
orderHandler := &AmqpOrderHandler{
Url: amqpUrl,
}
klarnaClient := NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient)
mux := http.NewServeMux()
mux.Handle("/cart/", http.StripPrefix("/cart", syncedServer.Serve()))
// only for local
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
pool.AddRemote(r.PathValue("host"))
})
// mux.HandleFunc("GET /save", app.HandleSave)
//mux.HandleFunc("/", app.RewritePath)
mux.HandleFunc("/debug/pprof/", pprof.Index)
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
mux.Handle("/metrics", promhttp.Handler())
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
// Grain pool health: simple capacity check (mirrors previous GrainHandler.IsHealthy)
grainCount, capacity := app.pool.LocalUsage()
if grainCount >= capacity {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("grain pool at capacity"))
return
}
if !pool.IsHealthy() {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("control plane not healthy"))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/checkout", func(w http.ResponseWriter, r *http.Request) {
orderId := r.URL.Query().Get("order_id")
order := &CheckoutOrder{}
if orderId == "" {
cookie, err := r.Cookie("cartid")
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}
if cookie.Value == "" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("no cart id to checkout is empty"))
return
}
parsed, ok := ParseCartId(cookie.Value)
if !ok {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("invalid cart id format"))
return
}
cartId := parsed
syncedServer.ProxyHandler(func(w http.ResponseWriter, r *http.Request, cartId CartId) error {
order, err = syncedServer.CreateOrUpdateCheckout(r.Host, cartId)
if err != nil {
return err
}
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)
fmt.Fprintf(w, tpl, order.HTMLSnippet)
return nil
})(cartId, w, r)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
// v2: Apply now returns *CartGrain; order creation handled inside grain (no payload to unmarshal)
} else {
order, err = klarnaClient.GetOrder(orderId)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}
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)
fmt.Fprintf(w, tpl, order.HTMLSnippet)
}
})
mux.HandleFunc("/confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) {
orderId := r.PathValue("order_id")
order, err := klarnaClient.GetOrder(orderId)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if order.Status == "checkout_complete" {
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: "",
Path: "/",
Secure: true,
HttpOnly: true,
Expires: time.Unix(0, 0),
SameSite: http.SameSiteLaxMode,
})
}
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, tpl, order.HTMLSnippet)
})
mux.HandleFunc("/validate", func(w http.ResponseWriter, r *http.Request) {
log.Printf("Klarna order validation, method: %s", r.Method)
if r.Method != "POST" {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
order := &CheckoutOrder{}
err := json.NewDecoder(r.Body).Decode(order)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
}
log.Printf("Klarna order validation: %s", order.ID)
//err = confirmOrder(order, orderHandler)
//if err != nil {
// log.Printf("Error validating order: %v\n", err)
// w.WriteHeader(http.StatusInternalServerError)
// return
//}
//
//err = triggerOrderCompleted(err, syncedServer, order)
//if err != nil {
// log.Printf("Error processing cart message: %v\n", err)
// w.WriteHeader(http.StatusInternalServerError)
// return
//}
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("/push", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
orderId := r.URL.Query().Get("order_id")
log.Printf("Order confirmation push: %s", orderId)
order, err := klarnaClient.GetOrder(orderId)
if err != nil {
log.Printf("Error creating request: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = confirmOrder(order, orderHandler)
if err != nil {
log.Printf("Error confirming order: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = triggerOrderCompleted(err, syncedServer, order)
if err != nil {
log.Printf("Error processing cart message: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = klarnaClient.AcknowledgeOrder(orderId)
if err != nil {
log.Printf("Error acknowledging order: %v\n", err)
}
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("1.0.0"))
})
sigs := make(chan os.Signal, 1)
done := make(chan bool, 1)
signal.Notify(sigs, syscall.SIGTERM)
go func() {
sig := <-sigs
fmt.Println("Shutting down due to signal:", sig)
//app.Save()
pool.Close()
done <- true
}()
log.Print("Server started at port 8080")
go http.ListenAndServe(":8080", mux)
<-done
}
func triggerOrderCompleted(err error, syncedServer *PoolServer, order *CheckoutOrder) error {
mutation := &messages.OrderCreated{
OrderId: order.ID,
Status: order.Status,
}
cid, ok := ParseCartId(order.MerchantReference1)
if !ok {
return fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1)
}
_, applyErr := syncedServer.pool.Apply(uint64(cid), mutation)
if applyErr == nil {
_ = AppendCartEvent(cid, mutation)
}
return applyErr
}
func confirmOrder(order *CheckoutOrder, orderHandler *AmqpOrderHandler) error {
orderToSend, err := json.Marshal(order)
if err != nil {
return err
}
err = orderHandler.Connect()
if err != nil {
return err
}
defer orderHandler.Close()
err = orderHandler.OrderCompleted(orderToSend)
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,82 @@
package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_add_item.go
//
// Registers the AddItem cart mutation in the generic mutation registry.
// This replaces the legacy switch-based logic previously found in CartGrain.Apply.
//
// Behavior:
// * Validates quantity > 0
// * If an item with same SKU exists -> increases quantity
// * Else creates a new CartItem with computed tax amounts
// * Totals recalculated automatically via WithTotals()
//
// NOTE: Any future field additions in messages.AddItem that affect pricing / tax
// must keep this handler in sync.
func init() {
RegisterMutation[messages.AddItem](
"AddItem",
func(g *CartGrain, m *messages.AddItem) error {
if m == nil {
return fmt.Errorf("AddItem: nil payload")
}
if m.Quantity < 1 {
return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity)
}
// Fast path: merge with existing item having same SKU
if existing, found := g.FindItemWithSku(m.Sku); found {
existing.Quantity += int(m.Quantity)
return nil
}
g.mu.Lock()
defer g.mu.Unlock()
g.lastItemId++
taxRate := 2500
if m.Tax > 0 {
taxRate = int(m.Tax)
}
taxAmountPerUnit := GetTaxAmount(m.Price, taxRate)
g.Items = append(g.Items, &CartItem{
Id: g.lastItemId,
ItemId: int(m.ItemId),
Quantity: int(m.Quantity),
Sku: m.Sku,
Name: m.Name,
Price: m.Price,
TotalPrice: m.Price * int64(m.Quantity),
TotalTax: int64(taxAmountPerUnit * int64(m.Quantity)),
Image: m.Image,
Stock: StockStatus(m.Stock),
Disclaimer: m.Disclaimer,
Brand: m.Brand,
Category: m.Category,
Category2: m.Category2,
Category3: m.Category3,
Category4: m.Category4,
Category5: m.Category5,
OrgPrice: m.OrgPrice,
ArticleType: m.ArticleType,
Outlet: m.Outlet,
SellerId: m.SellerId,
SellerName: m.SellerName,
Tax: int(taxAmountPerUnit),
TaxRate: taxRate,
StoreId: m.StoreId,
})
return nil
},
WithTotals(), // Recalculate totals after successful mutation
)
}

View File

@@ -0,0 +1,61 @@
package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_add_request.go
//
// Registers the AddRequest mutation. This mutation is a higher-level intent
// (add by SKU + quantity) which may translate into either:
// - Increasing quantity of an existing line (same SKU), OR
// - Creating a new item by performing a product lookup (via getItemData inside CartGrain.AddItem)
//
// Behavior:
// - Validates non-empty SKU and quantity > 0
// - If an item with the SKU already exists: increments its quantity
// - Else delegates to CartGrain.AddItem (which itself produces an AddItem mutation)
// - Totals recalculated automatically (WithTotals)
//
// NOTE:
// - This handler purposely avoids duplicating the detailed AddItem logic;
// it reuses CartGrain.AddItem which then flows through the AddItem mutation
// registry handler.
// - Double total recalculation can occur (AddItem has WithTotals too), but
// is acceptable for clarity. Optimize later if needed.
//
// Potential future improvements:
// - Stock validation before increasing quantity
// - Reservation logic or concurrency guards around stock updates
// - Coupon / pricing rules applied conditionally during add-by-sku
func init() {
RegisterMutation[messages.AddRequest](
"AddRequest",
func(g *CartGrain, m *messages.AddRequest) error {
if m == nil {
return fmt.Errorf("AddRequest: nil payload")
}
if m.Sku == "" {
return fmt.Errorf("AddRequest: sku is empty")
}
if m.Quantity < 1 {
return fmt.Errorf("AddRequest: invalid quantity %d", m.Quantity)
}
// Existing line: accumulate quantity only.
if existing, found := g.FindItemWithSku(m.Sku); found {
existing.Quantity += int(m.Quantity)
return nil
}
// New line: delegate to higher-level AddItem flow (product lookup).
// We intentionally ignore the returned *CartGrain; registry will
// do totals again after this handler returns (harmless).
_, err := g.AddItem(m.Sku, int(m.Quantity), m.Country, m.StoreId)
return err
},
WithTotals(),
)
}

View File

@@ -0,0 +1,58 @@
package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_change_quantity.go
//
// Registers the ChangeQuantity mutation.
//
// Behavior:
// - Locates an item by its cart-local line item Id (not source item_id).
// - If requested quantity <= 0 the line is removed.
// - Otherwise the line's Quantity field is updated.
// - Totals are recalculated (WithTotals).
//
// Error handling:
// - Returns an error if the item Id is not found.
// - Returns an error if payload is nil (defensive).
//
// Concurrency:
// - Uses the grain's RW-safe mutation pattern: we mutate in place under
// the grain's implicit expectation that higher layers control access.
// (If strict locking is required around every mutation, wrap logic in
// an explicit g.mu.Lock()/Unlock(), but current model mirrors prior code.)
func init() {
RegisterMutation[messages.ChangeQuantity](
"ChangeQuantity",
func(g *CartGrain, m *messages.ChangeQuantity) error {
if m == nil {
return fmt.Errorf("ChangeQuantity: nil payload")
}
foundIndex := -1
for i, it := range g.Items {
if it.Id == int(m.Id) {
foundIndex = i
break
}
}
if foundIndex == -1 {
return fmt.Errorf("ChangeQuantity: item id %d not found", m.Id)
}
if m.Quantity <= 0 {
// Remove the item
g.Items = append(g.Items[:foundIndex], g.Items[foundIndex+1:]...)
return nil
}
g.Items[foundIndex].Quantity = int(m.Quantity)
return nil
},
WithTotals(),
)
}

View File

@@ -0,0 +1,49 @@
package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_initialize_checkout.go
//
// Registers the InitializeCheckout mutation.
// This mutation is invoked AFTER an external Klarna checkout session
// has been successfully created or updated. It persists the Klarna
// order reference / status and marks the cart as having a payment in progress.
//
// Behavior:
// - Sets OrderReference to the Klarna order ID (overwriting if already set).
// - Sets PaymentStatus to the current Klarna status.
// - Sets / updates PaymentInProgress flag.
// - Does NOT alter pricing or line items (so no totals recalculation).
//
// Validation:
// - Returns an error if payload is nil.
// - Returns an error if orderId is empty (integrity guard).
//
// Concurrency:
// - Relies on upstream mutation serialization for a single grain. If
// parallel checkout attempts are possible, add higher-level guards
// (e.g. reject if PaymentInProgress already true unless reusing
// the same OrderReference).
func init() {
RegisterMutation[messages.InitializeCheckout](
"InitializeCheckout",
func(g *CartGrain, m *messages.InitializeCheckout) error {
if m == nil {
return fmt.Errorf("InitializeCheckout: nil payload")
}
if m.OrderId == "" {
return fmt.Errorf("InitializeCheckout: missing orderId")
}
g.OrderReference = m.OrderId
g.PaymentStatus = m.Status
g.PaymentInProgress = m.PaymentInProgress
return nil
},
// No WithTotals(): monetary aggregates are unaffected.
)
}

View File

@@ -0,0 +1,53 @@
package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_order_created.go
//
// Registers the OrderCreated mutation.
//
// This mutation represents the completion (or state transition) of an order
// initiated earlier via InitializeCheckout / external Klarna processing.
// It finalizes (or updates) the cart's order metadata.
//
// Behavior:
// - Validates payload non-nil and OrderId not empty.
// - Sets (or overwrites) OrderReference with the provided OrderId.
// - Sets PaymentStatus from payload.Status.
// - Marks PaymentInProgress = false (checkout flow finished / acknowledged).
// - Does NOT adjust monetary totals (no WithTotals()).
//
// Notes / Future Extensions:
// - If multiple order completion events can arrive (e.g., retries / webhook
// replays), this handler is idempotent: it simply overwrites fields.
// - If you need to guard against conflicting order IDs, add a check:
// if g.OrderReference != "" && g.OrderReference != m.OrderId { ... }
// - Add audit logging or metrics here if required.
//
// Concurrency:
// - Relies on the higher-level guarantee that Apply() calls are serialized
// per grain. If out-of-order events are possible, embed versioning or
// timestamps in the mutation and compare before applying changes.
func init() {
RegisterMutation[messages.OrderCreated](
"OrderCreated",
func(g *CartGrain, m *messages.OrderCreated) error {
if m == nil {
return fmt.Errorf("OrderCreated: nil payload")
}
if m.OrderId == "" {
return fmt.Errorf("OrderCreated: missing orderId")
}
g.OrderReference = m.OrderId
g.PaymentStatus = m.Status
g.PaymentInProgress = false
return nil
},
// No WithTotals(): order completion does not modify pricing or taxes.
)
}

View File

@@ -0,0 +1,301 @@
package main
import (
"fmt"
"reflect"
"sync"
)
// mutation_registry.go
//
// Mutation Registry Infrastructure
// --------------------------------
// This file introduces a generic registry for cart mutations that:
//
// 1. Decouples mutation logic from the large type-switch inside CartGrain.Apply.
// 2. Enforces (at registration time) that every mutation handler has the correct
// signature: func(*CartGrain, *T) error
// 3. Optionally auto-updates cart totals after a mutation if flagged.
// 4. Provides a single authoritative list of registered mutations for
// introspection / coverage testing.
// 5. Allows incremental migration: you can first register new mutations here,
// and later prune the legacy switch cases.
//
// Usage Pattern
// -------------
// // Define your mutation proto message (e.g. messages.ApplyCoupon in messages.proto)
// // Regenerate protobufs.
//
// // In an init() (ideally in a small file like mutations_apply_coupon.go)
// func init() {
// RegisterMutation[*messages.ApplyCoupon](
// "ApplyCoupon",
// func(g *CartGrain, m *messages.ApplyCoupon) error {
// // domain logic ...
// discount := int64(5000)
// if g.TotalPrice < discount {
// discount = g.TotalPrice
// }
// g.TotalDiscount += discount
// g.TotalPrice -= discount
// return nil
// },
// WithTotals(), // we changed price-related fields; recalc totals
// )
// }
//
// // To invoke dynamically (alternative to the current switch):
// if updated, err := ApplyRegistered(grain, incomingMessage); err == nil {
// grain = updated
// } else if errors.Is(err, ErrMutationNotRegistered) {
// // fallback to legacy switch logic
// }
//
// Migration Strategy
// ------------------
// 1. For each existing mutation handled in CartGrain.Apply, add a registry
// registration with equivalent logic.
// 2. Add a test that enumerates all *expected* mutation proto types and asserts
// they are present in RegisteredMutationTypes().
// 3. Once coverage is 100%, replace the switch in CartGrain.Apply with a call
// to ApplyRegistered (and optionally keep a minimal default to produce an
// "unsupported mutation" error).
//
// Thread Safety
// -------------
// Registration is typically done at init() time; a RWMutex provides safety
// should late dynamic registration ever be introduced.
//
// Auto Totals
// -----------
// Many mutations require recomputing totals. To avoid forgetting this, pass
// WithTotals() when registering. This will invoke grain.UpdateTotals() after
// the handler returns successfully.
//
// Error Semantics
// ---------------
// - If a handler returns an error, totals are NOT recalculated (even if
// WithTotals() was specified).
// - ApplyRegistered returns (nil, ErrMutationNotRegistered) if the message type
// is absent.
//
// Extensibility
// -------------
// It is straightforward to add options like audit hooks, metrics wrappers,
// or optimistic concurrency guards by extending MutationOption.
//
// NOTE: Generics require Go 1.18+. If constrained to earlier Go versions,
// replace the generic registration with a non-generic RegisterMutationType
// that accepts reflect.Type and an adapter function.
//
// ---------------------------------------------------------------------------
var (
mutationRegistryMu sync.RWMutex
mutationRegistry = make(map[reflect.Type]*registeredMutation)
// ErrMutationNotRegistered is returned when no handler exists for a given mutation type.
ErrMutationNotRegistered = fmt.Errorf("mutation not registered")
)
// MutationOption configures additional behavior for a registered mutation.
type MutationOption func(*mutationOptions)
// mutationOptions holds flags adjustable per registration.
type mutationOptions struct {
updateTotals bool
}
// WithTotals ensures CartGrain.UpdateTotals() is called after a successful handler.
func WithTotals() MutationOption {
return func(o *mutationOptions) {
o.updateTotals = true
}
}
// registeredMutation stores metadata + the execution closure.
type registeredMutation struct {
name string
handler func(*CartGrain, interface{}) error
updateTotals bool
msgType reflect.Type
}
// RegisterMutation registers a mutation handler for a specific message type T.
//
// Parameters:
//
// name - a human-readable identifier (used for diagnostics / coverage tests).
// handler - business logic operating on the cart grain & strongly typed message.
// options - optional behavior flags (e.g., WithTotals()).
//
// Panics if:
// - name is empty
// - handler is nil
// - duplicate registration for the same message type T
//
// Typical call is placed in an init() function.
func RegisterMutation[T any](name string, handler func(*CartGrain, *T) error, options ...MutationOption) {
if name == "" {
panic("RegisterMutation: name is required")
}
if handler == nil {
panic("RegisterMutation: handler is nil")
}
// Derive the reflect.Type for *T then its Elem (T) for mapping.
var zero *T
rtPtr := reflect.TypeOf(zero)
if rtPtr.Kind() != reflect.Ptr {
panic("RegisterMutation: expected pointer type for generic parameter")
}
rt := rtPtr.Elem()
opts := mutationOptions{}
for _, opt := range options {
opt(&opts)
}
wrapped := func(g *CartGrain, m interface{}) error {
typed, ok := m.(*T)
if !ok {
return fmt.Errorf("mutation type mismatch: have %T want *%s", m, rt.Name())
}
return handler(g, typed)
}
mutationRegistryMu.Lock()
defer mutationRegistryMu.Unlock()
if _, exists := mutationRegistry[rt]; exists {
panic(fmt.Sprintf("RegisterMutation: duplicate registration for type %s", rt.String()))
}
mutationRegistry[rt] = &registeredMutation{
name: name,
handler: wrapped,
updateTotals: opts.updateTotals,
msgType: rt,
}
}
// ApplyRegistered attempts to apply a registered mutation.
// Returns updated grain if successful.
//
// If the mutation is not registered, returns (nil, ErrMutationNotRegistered).
func ApplyRegistered(grain *CartGrain, msg interface{}) (*CartGrain, error) {
if grain == nil {
return nil, fmt.Errorf("nil grain")
}
if msg == nil {
return nil, fmt.Errorf("nil mutation message")
}
rt := indirectType(reflect.TypeOf(msg))
mutationRegistryMu.RLock()
entry, ok := mutationRegistry[rt]
mutationRegistryMu.RUnlock()
if !ok {
return nil, ErrMutationNotRegistered
}
if err := entry.handler(grain, msg); err != nil {
return nil, err
}
if entry.updateTotals {
grain.UpdateTotals()
}
return grain, nil
}
// RegisteredMutations returns metadata for all registered mutations (snapshot).
func RegisteredMutations() []string {
mutationRegistryMu.RLock()
defer mutationRegistryMu.RUnlock()
out := make([]string, 0, len(mutationRegistry))
for _, entry := range mutationRegistry {
out = append(out, entry.name)
}
return out
}
// RegisteredMutationTypes returns the reflect.Type list of all registered messages.
// Useful for coverage tests ensuring expected set matches actual set.
func RegisteredMutationTypes() []reflect.Type {
mutationRegistryMu.RLock()
defer mutationRegistryMu.RUnlock()
out := make([]reflect.Type, 0, len(mutationRegistry))
for t := range mutationRegistry {
out = append(out, t)
}
return out
}
// MustAssertMutationCoverage can be called at startup to ensure every expected
// mutation type has been registered. It panics with a descriptive message if any
// are missing. Provide a slice of prototype pointers (e.g. []*messages.AddItem{nil} ...)
func MustAssertMutationCoverage(expected []interface{}) {
mutationRegistryMu.RLock()
defer mutationRegistryMu.RUnlock()
missing := make([]string, 0)
for _, ex := range expected {
if ex == nil {
continue
}
t := indirectType(reflect.TypeOf(ex))
if _, ok := mutationRegistry[t]; !ok {
missing = append(missing, t.String())
}
}
if len(missing) > 0 {
panic(fmt.Sprintf("mutation registry missing handlers for: %v", missing))
}
}
// indirectType returns the element type if given a pointer; otherwise the type itself.
func indirectType(t reflect.Type) reflect.Type {
for t.Kind() == reflect.Ptr {
t = t.Elem()
}
return t
}
/*
Integration Guide
-----------------
1. Register all existing mutations:
func init() {
RegisterMutation[*messages.AddItem]("AddItem",
func(g *CartGrain, m *messages.AddItem) error {
// (port logic from existing switch branch)
// ...
return nil
},
WithTotals(),
)
// ... repeat for others
}
2. In CartGrain.Apply (early in the method) add:
if updated, err := ApplyRegistered(c, content); err == nil {
return updated, nil
} else if err != ErrMutationNotRegistered {
return nil, err
}
// existing switch fallback below
3. Once all mutations are registered, remove the legacy switch cases
and leave a single ErrMutationNotRegistered path for unknown types.
4. Add a coverage test (see docs for example; removed from source for clarity).
5. (Optional) Add metrics / tracing wrappers for handlers.
*/

View File

@@ -0,0 +1,53 @@
package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_remove_delivery.go
//
// Registers the RemoveDelivery mutation.
//
// Behavior:
// - Removes the delivery entry whose Id == payload.Id.
// - If not found, returns an error.
// - Cart totals are recalculated (WithTotals) after removal.
// - Items previously associated with that delivery simply become "without delivery";
// subsequent delivery mutations can reassign them.
//
// Differences vs legacy:
// - Legacy logic decremented TotalPrice explicitly before recalculating.
// Here we rely solely on UpdateTotals() to recompute from remaining
// deliveries and items (simpler / single source of truth).
//
// Future considerations:
// - If delivery pricing logic changes (e.g., dynamic taxes per delivery),
// UpdateTotals() may need enhancement to incorporate delivery tax properly.
func init() {
RegisterMutation[messages.RemoveDelivery](
"RemoveDelivery",
func(g *CartGrain, m *messages.RemoveDelivery) error {
if m == nil {
return fmt.Errorf("RemoveDelivery: nil payload")
}
targetID := int(m.Id)
index := -1
for i, d := range g.Deliveries {
if d.Id == targetID {
index = i
break
}
}
if index == -1 {
return fmt.Errorf("RemoveDelivery: delivery id %d not found", m.Id)
}
// Remove delivery (order not preserved beyond necessity)
g.Deliveries = append(g.Deliveries[:index], g.Deliveries[index+1:]...)
return nil
},
WithTotals(),
)
}

View File

@@ -0,0 +1,49 @@
package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_remove_item.go
//
// Registers the RemoveItem mutation.
//
// Behavior:
// - Removes the cart line whose local cart line Id == payload.Id
// - If no such line exists returns an error
// - Recalculates cart totals (WithTotals)
//
// Notes:
// - This removes only the line item; any deliveries referencing the removed
// item are NOT automatically adjusted (mirrors prior logic). If future
// semantics require pruning delivery.item_ids you can extend this handler.
// - If multiple lines somehow shared the same Id (should not happen), only
// the first match would be removed—data integrity relies on unique line Ids.
func init() {
RegisterMutation[messages.RemoveItem](
"RemoveItem",
func(g *CartGrain, m *messages.RemoveItem) error {
if m == nil {
return fmt.Errorf("RemoveItem: nil payload")
}
targetID := int(m.Id)
index := -1
for i, it := range g.Items {
if it.Id == targetID {
index = i
break
}
}
if index == -1 {
return fmt.Errorf("RemoveItem: item id %d not found", m.Id)
}
g.Items = append(g.Items[:index], g.Items[index+1:]...)
return nil
},
WithTotals(),
)
}

View File

@@ -0,0 +1,57 @@
package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_set_cart_items.go
//
// Registers the SetCartRequest mutation. This mutation replaces the entire list
// of cart items with the provided list (each entry is an AddRequest).
//
// Behavior:
// - Clears existing items (but leaves deliveries intact).
// - Iterates over each AddRequest and delegates to CartGrain.AddItem
// (which performs product lookup, creates AddItem mutation).
// - If any single addition fails, the mutation aborts with an error;
// items added prior to the failure remain (consistent with previous behavior).
// - Totals recalculated after completion via WithTotals().
//
// Notes:
// - Potential optimization: batch product lookups; currently sequential.
// - Consider adding rollback semantics if atomic replacement is desired.
// - Deliveries might reference item IDs that are now invalid—original logic
// also left deliveries untouched. If that becomes an issue, add a cleanup
// pass to remove deliveries whose item IDs no longer exist.
func init() {
RegisterMutation[messages.SetCartRequest](
"SetCartRequest",
func(g *CartGrain, m *messages.SetCartRequest) error {
if m == nil {
return fmt.Errorf("SetCartRequest: nil payload")
}
// Clear current items (keep deliveries)
g.mu.Lock()
g.Items = make([]*CartItem, 0, len(m.Items))
g.mu.Unlock()
for _, it := range m.Items {
if it == nil {
continue
}
if it.Sku == "" || it.Quantity < 1 {
return fmt.Errorf("SetCartRequest: invalid item (sku='%s' qty=%d)", it.Sku, it.Quantity)
}
_, err := g.AddItem(it.Sku, int(it.Quantity), it.Country, it.StoreId)
if err != nil {
return fmt.Errorf("SetCartRequest: add sku '%s' failed: %w", it.Sku, err)
}
}
return nil
},
WithTotals(),
)
}

View File

@@ -0,0 +1,101 @@
package main
import (
"fmt"
"slices"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_set_delivery.go
//
// Registers the SetDelivery mutation.
//
// Semantics (mirrors legacy switch logic):
// - If the payload specifies an explicit list of item IDs (payload.Items):
// - Each referenced cart line must exist.
// - None of the referenced items may already belong to a delivery.
// - Only those items are associated with the new delivery.
// - If payload.Items is empty:
// - All items currently without any delivery are associated with the new delivery.
// - A new delivery line is created with:
// - Auto-incremented delivery ID (cart-local)
// - Provider from payload
// - Fixed price (currently hard-coded: 4900 minor units) adjust as needed
// - Optional PickupPoint copied from payload
// - Cart totals are recalculated (WithTotals)
//
// Error cases:
// - Referenced item does not exist
// - Referenced item already has a delivery
// - No items qualify (resulting association set empty) -> returns error (prevents creating empty delivery)
//
// Concurrency:
// - Uses g.mu to protect lastDeliveryId increment and append to Deliveries slice.
// Item scans are read-only and performed outside the lock for simplicity;
// if stricter guarantees are needed, widen the lock section.
//
// Future extension points:
// - Variable delivery pricing (based on weight, distance, provider, etc.)
// - Validation of provider codes
// - Multi-currency delivery pricing
func init() {
RegisterMutation[messages.SetDelivery](
"SetDelivery",
func(g *CartGrain, m *messages.SetDelivery) error {
if m == nil {
return fmt.Errorf("SetDelivery: nil payload")
}
if m.Provider == "" {
return fmt.Errorf("SetDelivery: provider is empty")
}
withDelivery := g.ItemsWithDelivery()
targetItems := make([]int, 0)
if len(m.Items) == 0 {
// Use every item currently without a delivery
targetItems = append(targetItems, g.ItemsWithoutDelivery()...)
} else {
// Validate explicit list
for _, id64 := range m.Items {
id := int(id64)
found := false
for _, it := range g.Items {
if it.Id == id {
found = true
break
}
}
if !found {
return fmt.Errorf("SetDelivery: item id %d not found", id)
}
if slices.Contains(withDelivery, id) {
return fmt.Errorf("SetDelivery: item id %d already has a delivery", id)
}
targetItems = append(targetItems, id)
}
}
if len(targetItems) == 0 {
return fmt.Errorf("SetDelivery: no eligible items to attach")
}
// Append new delivery
g.mu.Lock()
g.lastDeliveryId++
newId := g.lastDeliveryId
g.Deliveries = append(g.Deliveries, &CartDelivery{
Id: newId,
Provider: m.Provider,
PickupPoint: m.PickupPoint,
Price: 4900, // TODO: externalize pricing
Items: targetItems,
})
g.mu.Unlock()
return nil
},
WithTotals(),
)
}

View File

@@ -0,0 +1,56 @@
package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_set_pickup_point.go
//
// Registers the SetPickupPoint mutation using the generic mutation registry.
//
// Semantics (mirrors original switch-based implementation):
// - Locate the delivery with Id == payload.DeliveryId
// - Set (or overwrite) its PickupPoint with the provided data
// - Does NOT alter pricing or taxes (so no totals recalculation required)
//
// Validation / Error Handling:
// - If payload is nil -> error
// - If DeliveryId not found -> error
//
// Concurrency:
// - Relies on the existing expectation that higher-level mutation routing
// serializes Apply() calls per grain; if stricter guarantees are needed,
// a delivery-level lock could be introduced later.
//
// Future Extensions:
// - Validate pickup point fields (country code, zip format, etc.)
// - Track history / audit of pickup point changes
// - Trigger delivery price adjustments (which would then require WithTotals()).
func init() {
RegisterMutation[messages.SetPickupPoint](
"SetPickupPoint",
func(g *CartGrain, m *messages.SetPickupPoint) error {
if m == nil {
return fmt.Errorf("SetPickupPoint: nil payload")
}
for _, d := range g.Deliveries {
if d.Id == int(m.DeliveryId) {
d.PickupPoint = &messages.PickupPoint{
Id: m.Id,
Name: m.Name,
Address: m.Address,
City: m.City,
Zip: m.Zip,
Country: m.Country,
}
return nil
}
}
return fmt.Errorf("SetPickupPoint: delivery id %d not found", m.DeliveryId)
},
// No WithTotals(): pickup point does not change pricing / tax.
)
}

414
cmd/cart/pool-server.go Normal file
View File

@@ -0,0 +1,414 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"time"
"git.tornberg.me/go-cart-actor/pkg/actor"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
type PoolServer struct {
pod_name string
pool actor.GrainPool[*CartGrain]
klarnaClient *KlarnaClient
}
func NewPoolServer(pool actor.GrainPool[*CartGrain], pod_name string, klarnaClient *KlarnaClient) *PoolServer {
return &PoolServer{
pod_name: pod_name,
pool: pool,
klarnaClient: klarnaClient,
}
}
func (s *PoolServer) ApplyLocal(id CartId, mutation interface{}) (*CartGrain, error) {
return s.pool.Apply(uint64(id), mutation)
}
func (s *PoolServer) HandleGet(w http.ResponseWriter, r *http.Request, id CartId) error {
grain, err := s.pool.Get(uint64(id))
if err != nil {
return err
}
return s.WriteResult(w, grain)
}
func (s *PoolServer) HandleAddSku(w http.ResponseWriter, r *http.Request, id CartId) error {
sku := r.PathValue("sku")
data, err := s.ApplyLocal(id, &messages.AddRequest{Sku: sku, Quantity: 1})
if err != nil {
return err
}
return s.WriteResult(w, data)
}
func (s *PoolServer) WriteResult(w http.ResponseWriter, result *CartGrain) 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) HandleDeleteItem(w http.ResponseWriter, r *http.Request, id CartId) error {
itemIdString := r.PathValue("itemId")
itemId, err := strconv.Atoi(itemIdString)
if err != nil {
return err
}
data, err := s.ApplyLocal(id, &messages.RemoveItem{Id: int64(itemId)})
if err != nil {
return err
}
return s.WriteResult(w, data)
}
type SetDelivery struct {
Provider string `json:"provider"`
Items []int64 `json:"items"`
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
}
func (s *PoolServer) HandleSetDelivery(w http.ResponseWriter, r *http.Request, id CartId) error {
delivery := SetDelivery{}
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) HandleSetPickupPoint(w http.ResponseWriter, r *http.Request, id CartId) error {
deliveryIdString := r.PathValue("deliveryId")
deliveryId, err := strconv.Atoi(deliveryIdString)
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: int64(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) HandleRemoveDelivery(w http.ResponseWriter, r *http.Request, id CartId) error {
deliveryIdString := r.PathValue("deliveryId")
deliveryId, err := strconv.Atoi(deliveryIdString)
if err != nil {
return err
}
reply, err := s.ApplyLocal(id, &messages.RemoveDelivery{Id: int64(deliveryId)})
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) HandleQuantityChange(w http.ResponseWriter, r *http.Request, id 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)
}
func (s *PoolServer) HandleSetCartItems(w http.ResponseWriter, r *http.Request, id CartId) error {
setCartItems := messages.SetCartRequest{}
err := json.NewDecoder(r.Body).Decode(&setCartItems)
if err != nil {
return err
}
reply, err := s.ApplyLocal(id, &setCartItems)
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) HandleAddRequest(w http.ResponseWriter, r *http.Request, id CartId) error {
addRequest := messages.AddRequest{}
err := json.NewDecoder(r.Body).Decode(&addRequest)
if err != nil {
return err
}
reply, err := s.ApplyLocal(id, &addRequest)
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 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.pool.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 CartId) (*CartGrain, error) {
// Persist initialization state via mutation (best-effort)
return s.pool.Apply(uint64(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 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 CartId
cookie, err := r.Cookie("cartid")
if err != nil || cookie.Value == "" {
id = 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 := ParseCartId(cookie.Value)
if !ok {
id = 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 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 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 CartId
raw := r.PathValue("id")
// If no id supplied, generate a new one
if raw == "" {
id := MustNewCartId()
w.Header().Set("Set-Cart-Id", id.String())
} else {
// Parse base62 cart id
if parsedId, ok := 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 CartId) error) func(cartId CartId, w http.ResponseWriter, r *http.Request) error {
return func(cartId CartId, w http.ResponseWriter, r *http.Request) error {
if ownerHost, ok := s.pool.OwnerHost(uint64(cartId)); ok {
handled, err := ownerHost.Proxy(uint64(cartId), w, r)
if err == nil && handled {
return nil
}
}
return fn(w, r, cartId)
}
}
func (s *PoolServer) Serve() *http.ServeMux {
mux := http.NewServeMux()
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)
})
mux.HandleFunc("GET /", CookieCartIdHandler(s.ProxyHandler(s.HandleGet)))
mux.HandleFunc("GET /add/{sku}", CookieCartIdHandler(s.ProxyHandler(s.HandleAddSku)))
mux.HandleFunc("POST /", CookieCartIdHandler(s.ProxyHandler(s.HandleAddRequest)))
mux.HandleFunc("POST /set", CookieCartIdHandler(s.ProxyHandler(s.HandleSetCartItems)))
mux.HandleFunc("DELETE /{itemId}", CookieCartIdHandler(s.ProxyHandler(s.HandleDeleteItem)))
mux.HandleFunc("PUT /", CookieCartIdHandler(s.ProxyHandler(s.HandleQuantityChange)))
mux.HandleFunc("DELETE /", CookieCartIdHandler(s.ProxyHandler(s.RemoveCartCookie)))
mux.HandleFunc("POST /delivery", CookieCartIdHandler(s.ProxyHandler(s.HandleSetDelivery)))
mux.HandleFunc("DELETE /delivery/{deliveryId}", CookieCartIdHandler(s.ProxyHandler(s.HandleRemoveDelivery)))
mux.HandleFunc("PUT /delivery/{deliveryId}/pickupPoint", CookieCartIdHandler(s.ProxyHandler(s.HandleSetPickupPoint)))
//mux.HandleFunc("GET /checkout", CookieCartIdHandler(s.ProxyHandler(s.HandleCheckout)))
//mux.HandleFunc("GET /confirmation/{orderId}", CookieCartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
mux.HandleFunc("GET /byid/{id}", CartIdHandler(s.ProxyHandler(s.HandleGet)))
mux.HandleFunc("GET /byid/{id}/add/{sku}", CartIdHandler(s.ProxyHandler(s.HandleAddSku)))
mux.HandleFunc("POST /byid/{id}", CartIdHandler(s.ProxyHandler(s.HandleAddRequest)))
mux.HandleFunc("DELETE /byid/{id}/{itemId}", CartIdHandler(s.ProxyHandler(s.HandleDeleteItem)))
mux.HandleFunc("PUT /byid/{id}", CartIdHandler(s.ProxyHandler(s.HandleQuantityChange)))
mux.HandleFunc("POST /byid/{id}/delivery", CartIdHandler(s.ProxyHandler(s.HandleSetDelivery)))
mux.HandleFunc("DELETE /byid/{id}/delivery/{deliveryId}", CartIdHandler(s.ProxyHandler(s.HandleRemoveDelivery)))
mux.HandleFunc("PUT /byid/{id}/delivery/{deliveryId}/pickupPoint", CartIdHandler(s.ProxyHandler(s.HandleSetPickupPoint)))
//mux.HandleFunc("GET /byid/{id}/checkout", CartIdHandler(s.ProxyHandler(s.HandleCheckout)))
//mux.HandleFunc("GET /byid/{id}/confirmation", CartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
return mux
}

View File

@@ -0,0 +1,35 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"github.com/matst80/slask-finder/pkg/index"
)
// TODO make this configurable
func getBaseUrl(country string) string {
// if country == "se" {
// return "http://s10n-se:8080"
// }
if country == "no" {
return "http://s10n-no.s10n:8080"
}
if country == "se" {
return "http://s10n-se.s10n:8080"
}
return "http://localhost:8082"
}
func FetchItem(sku string, country string) (*index.DataItem, error) {
baseUrl := getBaseUrl(country)
res, err := http.Get(fmt.Sprintf("%s/api/by-sku/%s", baseUrl, sku))
if err != nil {
return nil, err
}
defer res.Body.Close()
var item index.DataItem
err = json.NewDecoder(res.Body).Decode(&item)
return &item, err
}