56 Commits

Author SHA1 Message Date
matst80
e8fd0a21b1 Merge branch 'main' of git-ssh.tornberg.me:mats/go-cart-actor into refactor/http-proxy
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m23s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m35s
2025-10-14 23:11:20 +02:00
matst80
137958b83b update if same, else add new
Some checks failed
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m24s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-10-14 23:08:45 +02:00
f5014fe906 Complete refactor to new grpc control plane and only http proxy for carts (#4)
All checks were successful
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m14s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m54s
Co-authored-by: matst80 <mats.tornberg@gmail.com>
Reviewed-on: https://git.tornberg.me/mats/go-cart-actor/pulls/4
Co-authored-by: Mats Törnberg <mats@tornberg.me>
Co-committed-by: Mats Törnberg <mats@tornberg.me>
2025-10-14 22:31:12 +02:00
matst80
ee5d714220 m
All checks were successful
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m15s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m44s
2025-10-14 22:30:19 +02:00
matst80
5ca21e214f subtract from totalprice
Some checks failed
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m14s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-10-14 22:26:59 +02:00
matst80
2df64e715e fix mutation
Some checks are pending
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m30s
Build and Publish / BuildAndDeployArm64 (push) Has started running
2025-10-14 22:23:15 +02:00
matst80
e0ea03f6cf remove voucher
All checks were successful
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m17s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m0s
2025-10-14 22:18:49 +02:00
matst80
a7cbdcd0da try new vouchers
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m14s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m57s
2025-10-14 22:14:22 +02:00
matst80
f0b6a733f1 update
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m9s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m53s
2025-10-14 21:41:50 +02:00
matst80
c86a189795 parsestock
Some checks failed
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 39s
Build and Publish / BuildAndDeployArm64 (push) Failing after 3m26s
2025-10-14 21:36:52 +02:00
matst80
1565cfdaba change alot of id values
All checks were successful
Build and Publish / Metadata (push) Successful in 15s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m11s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m7s
2025-10-14 21:24:50 +02:00
matst80
4bec3f8cc0 check storestock
All checks were successful
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m22s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m58s
2025-10-14 20:19:29 +02:00
matst80
5e2025e30f test other tax
All checks were successful
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m20s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m15s
2025-10-14 20:00:32 +02:00
e908a4130b more tests
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m20s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m4s
2025-10-14 19:38:38 +02:00
606df6218a update cartitem layout
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m19s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m51s
2025-10-14 19:23:01 +02:00
matst80
9f83717ea9 try new errors
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m27s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m0s
2025-10-14 15:52:59 +02:00
matst80
b029a9d05a errors as string for now
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m30s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m6s
2025-10-14 15:36:50 +02:00
matst80
cbbae3dfd3 dont allow duplicate
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m16s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m52s
2025-10-14 14:52:42 +02:00
matst80
b842efae6a better logs
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m25s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m6s
2025-10-14 13:49:47 +02:00
matst80
bb9a595d25 log ownership change
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m20s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m3s
2025-10-14 13:43:10 +02:00
matst80
de62b0664d fix ownership
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m11s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m20s
2025-10-14 13:33:08 +02:00
matst80
0b9c14c231 more
All checks were successful
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m12s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m9s
2025-10-14 12:30:12 +02:00
matst80
7a79efbb9f tax calculations
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m15s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m37s
2025-10-14 09:13:38 +02:00
matst80
6c44a03dd1 test add vouchers and shared service
Some checks failed
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m22s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-10-14 09:10:01 +02:00
matst80
11e82de114 add manual openapi docs
All checks were successful
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m35s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m26s
2025-10-14 08:42:04 +02:00
matst80
67b9a739fe Update simple_grain_pool.go
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m25s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m8s
2025-10-13 22:50:09 +02:00
matst80
5c67579464 less logs
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m23s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m58s
2025-10-13 22:25:46 +02:00
matst80
22ac64c14d lock
All checks were successful
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m24s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m57s
2025-10-13 22:19:46 +02:00
27c866ce58 allow multiple
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m23s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m0s
2025-10-13 20:40:36 +02:00
c9701803d1 less logs
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m9s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m54s
2025-10-13 20:22:51 +02:00
dc12e8c3d5 take the correct type
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m25s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m45s
2025-10-13 20:10:49 +02:00
30b98f93ce get correct type
All checks were successful
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m8s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m54s
2025-10-13 20:01:41 +02:00
8e60cc2239 support multiple mutations
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m13s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m54s
2025-10-13 19:51:27 +02:00
matst80
91e398dcc3 more files
All checks were successful
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m26s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m2s
2025-10-13 19:05:24 +02:00
matst80
f3e92c7d65 queued disk stuff
Some checks are pending
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Has started running
Build and Publish / BuildAndDeployArm64 (push) Has started running
2025-10-13 19:05:12 +02:00
matst80
6fbd62936f cleanup
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m28s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m1s
2025-10-13 17:39:07 +02:00
matst80
2bf0475335 append async
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m28s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m3s
2025-10-13 17:23:17 +02:00
matst80
10e1affad0 update
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m21s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m0s
2025-10-13 16:25:33 +02:00
matst80
e26ad676b3 dont read forever
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m29s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m55s
2025-10-13 15:46:34 +02:00
matst80
9fc3871e84 better 2025-10-13 15:39:41 +02:00
matst80
6094da99f3 Update Dockerfile
All checks were successful
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m39s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m54s
2025-10-13 10:30:08 +02:00
matst80
1575b3a829 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
2025-10-13 10:29:55 +02:00
matst80
c6671ceef0 dont log pings
All checks were successful
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m13s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m46s
2025-10-12 23:29:25 +02:00
matst80
6cb46b4e16 async append to log
Some checks failed
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Has started running
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-10-12 23:28:05 +02:00
matst80
4e4d5371ec store reference to cart
Some checks failed
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m18s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-10-12 23:25:28 +02:00
matst80
c4f0c67580 Update grpc_server.go
Some checks failed
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m4s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-10-12 23:23:02 +02:00
matst80
6a9ebbf453 såh 2025-10-12 23:19:35 +02:00
matst80
ea35871676 test
Some checks failed
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m40s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2025-10-12 23:18:55 +02:00
matst80
7ad28966fb longer time
All checks were successful
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m10s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m47s
2025-10-12 23:06:11 +02:00
matst80
a7a778caaf mer
All checks were successful
Build and Publish / Metadata (push) Successful in 6s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m8s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m11s
2025-10-12 23:00:17 +02:00
matst80
873fb6c97b test
Some checks failed
Build and Publish / Metadata (push) Successful in 3s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m24s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-10-12 22:57:27 +02:00
matst80
33ef868295 faster deployment
All checks were successful
Build and Publish / Metadata (push) Successful in 6s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m4s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m20s
2025-10-12 22:49:12 +02:00
matst80
8d73f856bf test
Some checks failed
Build and Publish / Metadata (push) Successful in 5s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 48s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-10-12 22:47:13 +02:00
matst80
b591e3d3f5 update
All checks were successful
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 47s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m18s
2025-10-12 22:26:05 +02:00
matst80
b8266d80f9 more stuff
All checks were successful
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m50s
2025-10-12 21:36:00 +02:00
0ba7410162 even more refactoring
All checks were successful
Build and Publish / Metadata (push) Successful in 6s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 46s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m8s
2025-10-11 18:17:31 +02:00
65 changed files with 5578 additions and 3710 deletions

View File

@@ -57,7 +57,7 @@ RUN --mount=type=cache,target=/go/build-cache \
-X main.Version=${VERSION} \
-X main.GitCommit=${GIT_COMMIT} \
-X main.BuildDate=${BUILD_DATE}" \
-o /out/go-cart-actor .
-o /out/go-cart-actor ./cmd/cart
############################
# Runtime Stage

View File

@@ -69,8 +69,8 @@ check_tools:
protogen: check_tools
@echo "$(YELLOW)Generating protobuf code (outputs -> ./proto)...$(RESET)"
$(PROTOC) -I $(PROTO_DIR) \
--go_out=./proto --go_opt=paths=source_relative \
--go-grpc_out=./proto --go-grpc_opt=paths=source_relative \
--go_out=./pkg/messages --go_opt=paths=source_relative \
--go-grpc_out=./pkg/messages --go-grpc_opt=paths=source_relative \
$(PROTOS)
@echo "$(GREEN)Protobuf generation complete.$(RESET)"

BIN
cart Executable file

Binary file not shown.

View File

@@ -1,273 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"sync"
"time"
messages "git.tornberg.me/go-cart-actor/proto"
)
// 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
lastChange int64 // 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"`
}
type Grain interface {
GetId() CartId
Apply(content interface{}, isReplay bool) (*CartGrain, error)
GetCurrentState() (*CartGrain, error)
}
func (c *CartGrain) GetId() CartId {
return c.Id
}
func (c *CartGrain) GetLastChange() int64 {
return c.lastChange
}
func (c *CartGrain) GetCurrentState() (*CartGrain, error) {
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.t
if item.StockLevel == "0" || item.StockLevel == "" {
stock = OutOfStock
} else if item.StockLevel == "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)
}
/*
Legacy storage (event sourcing) removed in oneof refactor.
Kept stub (commented) for potential future reintroduction using proto envelopes.
func (c *CartGrain) GetStorageMessage(since int64) []interface{} {
return nil
}
*/
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 := false
for _, id := range hasDelivery {
if item.Id == id {
found = true
break
}
}
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) {
grainMutations.Inc()
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().Unix()
_ = 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)
}
}

5
cmd/backoffice/main.go Normal file
View File

@@ -0,0 +1,5 @@
package main
func main() {
// Your code here
}

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

@@ -0,0 +1,268 @@
package main
import (
"encoding/json"
"fmt"
"slices"
"sync"
"time"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"git.tornberg.me/go-cart-actor/pkg/voucher"
)
// 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
type ItemMeta struct {
Name string `json:"name"`
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"`
SellerId string `json:"sellerId,omitempty"`
SellerName string `json:"sellerName,omitempty"`
Image string `json:"image,omitempty"`
Outlet *string `json:"outlet,omitempty"`
}
type CartItem struct {
Id uint32 `json:"id"`
ItemId uint32 `json:"itemId,omitempty"`
ParentId uint32 `json:"parentId,omitempty"`
Sku string `json:"sku"`
Price Price `json:"price"`
TotalPrice Price `json:"totalPrice"`
OrgPrice *Price `json:"orgPrice,omitempty"`
Stock StockStatus `json:"stock"`
Quantity int `json:"qty"`
Discount *Price `json:"discount,omitempty"`
Disclaimer string `json:"disclaimer,omitempty"`
ArticleType string `json:"type,omitempty"`
StoreId *string `json:"storeId,omitempty"`
Meta *ItemMeta `json:"meta,omitempty"`
}
type CartDelivery struct {
Id uint32 `json:"id"`
Provider string `json:"provider"`
Price Price `json:"price"`
Items []uint32 `json:"items"`
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
}
type CartNotification struct {
LinkedId int `json:"id"`
Provider string `json:"provider"`
Title string `json:"title"`
Content string `json:"content"`
}
type CartGrain struct {
mu sync.RWMutex
lastItemId uint32
lastDeliveryId uint32
lastVoucherId uint32
lastAccess time.Time
lastChange time.Time // unix seconds of last successful mutation (replay sets from event ts)
userId string
Id CartId `json:"id"`
Items []*CartItem `json:"items"`
TotalPrice *Price `json:"totalPrice"`
TotalDiscount *Price `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"`
Vouchers []*Voucher `json:"vouchers,omitempty"`
Notifications []CartNotification `json:"cartNotification,omitempty"`
}
type Voucher struct {
Code string `json:"code"`
Rules []*messages.VoucherRule `json:"rules"`
Id uint32 `json:"id"`
Value int64 `json:"value"`
}
func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
// No rules -> applies to entire cart
if len(v.Rules) == 0 {
return cart.Items, true
}
// Build evaluation context once
ctx := voucher.EvalContext{
Items: make([]voucher.Item, 0, len(cart.Items)),
CartTotalInc: 0,
}
if cart.TotalPrice != nil {
ctx.CartTotalInc = cart.TotalPrice.IncVat
}
for _, it := range cart.Items {
category := ""
if it.Meta != nil {
category = it.Meta.Category
}
ctx.Items = append(ctx.Items, voucher.Item{
Sku: it.Sku,
Category: category,
UnitPrice: it.Price.IncVat,
})
}
// All voucher rules must pass (logical AND)
for _, rule := range v.Rules {
expr := rule.GetCondition()
if expr == "" {
// Empty condition treated as pass (acts like a comment / placeholder)
continue
}
rs, err := voucher.ParseRules(expr)
if err != nil {
// Fail closed on parse error
return nil, false
}
if !rs.Applies(ctx) {
return nil, false
}
}
return cart.Items, true
}
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 (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() []uint32 {
ret := make([]uint32, 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() []uint32 {
ret := make([]uint32, 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 (c *CartGrain) Apply(content proto.Message, 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 = NewPrice()
c.TotalDiscount = NewPrice()
for _, item := range c.Items {
rowTotal := MultiplyPrice(item.Price, int64(item.Quantity))
item.TotalPrice = *rowTotal
c.TotalPrice.Add(*rowTotal)
if item.OrgPrice != nil {
diff := NewPrice()
diff.Add(*item.OrgPrice)
diff.Subtract(item.Price)
if diff.IncVat > 0 {
c.TotalDiscount.Add(*diff)
}
}
}
for _, delivery := range c.Deliveries {
c.TotalPrice.Add(delivery.Price)
}
for _, voucher := range c.Vouchers {
if _, ok := voucher.AppliesTo(c); ok {
value := NewPriceFromIncVat(voucher.Value, 25)
c.TotalDiscount.Add(*value)
c.TotalPrice.Subtract(*value)
}
}
}

View File

@@ -0,0 +1,48 @@
package main
import (
"testing"
)
// helper to create a cart grain with items and deliveries
func newTestCart() *CartGrain {
return &CartGrain{Items: []*CartItem{}, Deliveries: []*CartDelivery{}, Vouchers: []*Voucher{}, Notifications: []CartNotification{}}
}
func TestCartGrainUpdateTotalsBasic(t *testing.T) {
c := newTestCart()
// Item1 price 1250 (ex 1000 vat 250) org price higher -> discount 200 per unit
item1Price := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}}
item1Org := &Price{IncVat: 1500, VatRates: map[float32]int64{25: 300}}
item2Price := Price{IncVat: 2000, VatRates: map[float32]int64{25: 400}}
c.Items = []*CartItem{
{Id: 1, Price: item1Price, OrgPrice: item1Org, Quantity: 2},
{Id: 2, Price: item2Price, OrgPrice: &item2Price, Quantity: 1},
}
deliveryPrice := Price{IncVat: 4900, VatRates: map[float32]int64{25: 980}}
c.Deliveries = []*CartDelivery{{Id: 1, Price: deliveryPrice, Items: []uint32{1, 2}}}
c.UpdateTotals()
// Expected totals: sum inc vat of items * qty plus delivery
// item1 total inc = 1250*2 = 2500
// item2 total inc = 2000*1 = 2000
// delivery inc = 4900
expectedInc := int64(2500 + 2000 + 4900)
if c.TotalPrice.IncVat != expectedInc {
t.Fatalf("TotalPrice IncVat expected %d got %d", expectedInc, c.TotalPrice.IncVat)
}
// Discount: current implementation computes (OrgPrice - Price) ignoring quantity -> 1500-1250=250
if c.TotalDiscount.IncVat != 250 {
t.Fatalf("TotalDiscount expected 250 got %d", c.TotalDiscount.IncVat)
}
}
func TestCartGrainUpdateTotalsNoItems(t *testing.T) {
c := newTestCart()
c.UpdateTotals()
if c.TotalPrice.IncVat != 0 || c.TotalDiscount.IncVat != 0 {
t.Fatalf("expected zero totals got %+v", c)
}
}

View File

@@ -64,20 +64,20 @@ func BuildCheckoutOrderPayload(grain *CartGrain, meta *CheckoutMeta) ([]byte, *C
lines = append(lines, &Line{
Type: "physical",
Reference: it.Sku,
Name: it.Name,
Name: it.Meta.Name,
Quantity: it.Quantity,
UnitPrice: int(it.Price),
UnitPrice: int(it.Price.IncVat),
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),
TotalAmount: int(it.TotalPrice.IncVat),
TotalTaxAmount: int(it.TotalPrice.TotalVat()),
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", it.Meta.Image),
})
}
// Delivery lines
for _, d := range grain.Deliveries {
if d == nil || d.Price <= 0 {
if d == nil || d.Price.IncVat <= 0 {
continue
}
lines = append(lines, &Line{
@@ -85,11 +85,11 @@ func BuildCheckoutOrderPayload(grain *CartGrain, meta *CheckoutMeta) ([]byte, *C
Reference: d.Provider,
Name: "Delivery",
Quantity: 1,
UnitPrice: int(d.Price),
UnitPrice: int(d.Price.IncVat),
TaxRate: 2500,
QuantityUnit: "st",
TotalAmount: int(d.Price),
TotalTaxAmount: int(GetTaxAmount(d.Price, 2500)),
TotalAmount: int(d.Price.IncVat),
TotalTaxAmount: int(d.Price.TotalVat()),
})
}
@@ -97,8 +97,8 @@ func BuildCheckoutOrderPayload(grain *CartGrain, meta *CheckoutMeta) ([]byte, *C
PurchaseCountry: country,
PurchaseCurrency: currency,
Locale: locale,
OrderAmount: int(grain.TotalPrice),
OrderTaxAmount: int(grain.TotalTax),
OrderAmount: int(grain.TotalPrice.IncVat),
OrderTaxAmount: int(grain.TotalPrice.TotalVat()),
OrderLines: lines,
MerchantReference1: grain.Id.String(),
MerchantURLS: &CheckoutMerchantURLS{

View File

@@ -12,10 +12,15 @@ import (
"syscall"
"time"
messages "git.tornberg.me/go-cart-actor/proto"
"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"
"git.tornberg.me/go-cart-actor/pkg/voucher"
"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"
)
@@ -35,55 +40,17 @@ var (
})
)
func spawn(id CartId) (*CartGrain, error) {
grainSpawns.Inc()
ret := &CartGrain{
lastItemId: 0,
lastDeliveryId: 0,
Deliveries: []*CartDelivery{},
Id: id,
Items: []*CartItem{},
TotalPrice: 0,
}
// Set baseline lastChange at spawn; replay may update it to last event timestamp.
ret.lastChange = time.Now().Unix()
// Legacy loadMessages (no-op) retained; then replay append-only event log
//_ = loadMessages(ret, id)
err := ReplayCartEvents(ret, id)
return ret, err
}
func init() {
os.Mkdir("data", 0755)
}
type App struct {
pool *CartPool
storage *DiskStorage
}
func (a *App) Save() error {
for id, grain := range a.pool.SnapshotGrains() {
if grain == nil {
continue
}
if grain.GetLastChange() > a.storage.LastSaves[uint64(id)] {
err := a.storage.Store(id, grain)
if err != nil {
log.Printf("Error saving grain %s: %v\n", id, err)
}
}
}
return nil
pool *actor.SimpleGrainPool[CartGrain]
}
var podIp = os.Getenv("POD_IP")
var name = os.Getenv("POD_NAME")
var amqpUrl = os.Getenv("AMQP_URL")
var KlarnaInstance = NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
var tpl = `<!DOCTYPE html>
<html lang="en">
@@ -103,10 +70,13 @@ func getCountryFromHost(host string) string {
if strings.Contains(strings.ToLower(host), "-no") {
return "no"
}
if strings.Contains(strings.ToLower(host), "-se") {
return "se"
}
return ""
}
func GetDiscovery() Discovery {
func GetDiscovery() discovery.Discovery {
if podIp == "" {
return nil
}
@@ -120,48 +90,139 @@ func GetDiscovery() Discovery {
if err != nil {
log.Fatalf("Error creating client: %v\n", err)
}
return NewK8sDiscovery(client)
return discovery.NewK8sDiscovery(client)
}
type MutationContext struct {
VoucherService voucher.Service
}
func main() {
storage, err := NewDiskStorage(fmt.Sprintf("data/s_%s.gob", name))
if err != nil {
log.Printf("Error loading state: %v\n", err)
controlPlaneConfig := actor.DefaultServerConfig()
reg := actor.NewMutationRegistry()
reg.RegisterMutations(
actor.NewMutation(AddItem, func() *messages.AddItem {
return &messages.AddItem{}
}),
actor.NewMutation(ChangeQuantity, func() *messages.ChangeQuantity {
return &messages.ChangeQuantity{}
}),
actor.NewMutation(RemoveItem, func() *messages.RemoveItem {
return &messages.RemoveItem{}
}),
actor.NewMutation(InitializeCheckout, func() *messages.InitializeCheckout {
return &messages.InitializeCheckout{}
}),
actor.NewMutation(OrderCreated, func() *messages.OrderCreated {
return &messages.OrderCreated{}
}),
actor.NewMutation(RemoveDelivery, func() *messages.RemoveDelivery {
return &messages.RemoveDelivery{}
}),
actor.NewMutation(SetDelivery, func() *messages.SetDelivery {
return &messages.SetDelivery{}
}),
actor.NewMutation(SetPickupPoint, func() *messages.SetPickupPoint {
return &messages.SetPickupPoint{}
}),
actor.NewMutation(ClearCart, func() *messages.ClearCartRequest {
return &messages.ClearCartRequest{}
}),
actor.NewMutation(AddVoucher, func() *messages.AddVoucher {
return &messages.AddVoucher{}
}),
actor.NewMutation(RemoveVoucher, func() *messages.RemoveVoucher {
return &messages.RemoveVoucher{}
}),
)
diskStorage := actor.NewDiskStorage[CartGrain]("data", reg)
poolConfig := actor.GrainPoolConfig[CartGrain]{
MutationRegistry: reg,
Storage: diskStorage,
Spawn: func(id uint64) (actor.Grain[CartGrain], error) {
grainSpawns.Inc()
ret := &CartGrain{
lastItemId: 0,
lastDeliveryId: 0,
Deliveries: []*CartDelivery{},
Id: CartId(id),
Items: []*CartItem{},
TotalPrice: NewPrice(),
}
pool, err := NewCartPool(2*65535, 15*time.Minute, podIp, spawn, GetDiscovery())
// Set baseline lastChange at spawn; replay may update it to last event timestamp.
ret.lastChange = time.Now()
ret.lastAccess = time.Now()
err := diskStorage.LoadEvents(id, ret)
return ret, err
},
SpawnHost: func(host string) (actor.Host, error) {
return proxy.NewRemoteHost(host)
},
TTL: 15 * time.Minute,
PoolSize: 2 * 65535,
Hostname: podIp,
}
pool, err := actor.NewSimpleGrainPool(poolConfig)
if err != nil {
log.Fatalf("Error creating cart pool: %v\n", err)
}
app := &App{
pool: pool,
storage: storage,
}
grpcSrv, err := StartGRPCServer(":1337", pool)
grpcSrv, err := actor.NewControlServer[*CartGrain](controlPlaneConfig, pool)
if err != nil {
log.Fatalf("Error starting gRPC server: %v\n", err)
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
}
defer grpcSrv.GracefulStop()
go func() {
for range time.Tick(time.Minute * 5) {
err := app.Save()
go diskStorage.SaveLoop(10 * time.Second)
go func(hw discovery.Discovery) {
if hw == nil {
log.Print("No discovery service available")
return
}
ch, err := hw.Watch()
if err != nil {
log.Printf("Error saving: %v\n", err)
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))
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) {
// syncedPool.AddRemote(r.PathValue("host"))
// })
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)
@@ -218,30 +279,42 @@ func main() {
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 = KlarnaInstance.GetOrder(orderId)
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)
w.Write([]byte(fmt.Sprintf(tpl, order.HTMLSnippet)))
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 := KlarnaInstance.GetOrder(orderId)
order, err := klarnaClient.GetOrder(orderId)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
@@ -263,7 +336,7 @@ func main() {
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(fmt.Sprintf(tpl, order.HTMLSnippet)))
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)
@@ -301,7 +374,7 @@ func main() {
orderId := r.URL.Query().Get("order_id")
log.Printf("Order confirmation push: %s", orderId)
order, err := KlarnaInstance.GetOrder(orderId)
order, err := klarnaClient.GetOrder(orderId)
if err != nil {
log.Printf("Error creating request: %v\n", err)
@@ -316,13 +389,13 @@ func main() {
return
}
err = triggerOrderCompleted(err, syncedServer, order)
err = triggerOrderCompleted(syncedServer, order)
if err != nil {
log.Printf("Error processing cart message: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = KlarnaInstance.AcknowledgeOrder(orderId)
err = klarnaClient.AcknowledgeOrder(orderId)
if err != nil {
log.Printf("Error acknowledging order: %v\n", err)
}
@@ -334,6 +407,8 @@ func main() {
w.Write([]byte("1.0.0"))
})
mux.HandleFunc("/openapi.json", ServeEmbeddedOpenAPI)
sigs := make(chan os.Signal, 1)
done := make(chan bool, 1)
signal.Notify(sigs, syscall.SIGTERM)
@@ -341,7 +416,7 @@ func main() {
go func() {
sig := <-sigs
fmt.Println("Shutting down due to signal:", sig)
app.Save()
diskStorage.Close()
pool.Close()
done <- true
@@ -353,7 +428,7 @@ func main() {
}
func triggerOrderCompleted(err error, syncedServer *PoolServer, order *CheckoutOrder) error {
func triggerOrderCompleted(syncedServer *PoolServer, order *CheckoutOrder) error {
mutation := &messages.OrderCreated{
OrderId: order.ID,
Status: order.Status,
@@ -362,10 +437,8 @@ func triggerOrderCompleted(err error, syncedServer *PoolServer, order *CheckoutO
if !ok {
return fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1)
}
_, applyErr := syncedServer.pool.Apply(cid, mutation)
if applyErr == nil {
_ = AppendCartEvent(cid, mutation)
}
_, applyErr := syncedServer.Apply(uint64(cid), mutation)
return applyErr
}

View File

@@ -0,0 +1,91 @@
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 AddItem(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 {
if existing.StoreId == m.StoreId {
existing.Quantity += int(m.Quantity)
existing.Stock = StockStatus(m.Stock)
existing.StoreId = m.StoreId
return nil
}
}
g.mu.Lock()
defer g.mu.Unlock()
g.lastItemId++
taxRate := float32(25.0)
if m.Tax > 0 {
taxRate = float32(int(m.Tax) / 100)
}
pricePerItem := NewPriceFromIncVat(m.Price, taxRate)
g.Items = append(g.Items, &CartItem{
Id: g.lastItemId,
ItemId: uint32(m.ItemId),
Quantity: int(m.Quantity),
Sku: m.Sku,
Meta: &ItemMeta{
Name: m.Name,
Image: m.Image,
Brand: m.Brand,
Category: m.Category,
Category2: m.Category2,
Category3: m.Category3,
Category4: m.Category4,
Category5: m.Category5,
Outlet: m.Outlet,
SellerId: m.SellerId,
SellerName: m.SellerName,
},
Price: *pricePerItem,
TotalPrice: *MultiplyPrice(*pricePerItem, int64(m.Quantity)),
Stock: StockStatus(m.Stock),
Disclaimer: m.Disclaimer,
OrgPrice: getOrgPrice(m.OrgPrice, taxRate),
ArticleType: m.ArticleType,
StoreId: m.StoreId,
})
g.UpdateTotals()
return nil
}
func getOrgPrice(orgPrice int64, taxRate float32) *Price {
if orgPrice <= 0 {
return nil
}
return NewPriceFromIncVat(orgPrice, taxRate)
}

View File

@@ -0,0 +1,64 @@
package main
import (
"slices"
"git.tornberg.me/go-cart-actor/pkg/actor"
"git.tornberg.me/go-cart-actor/pkg/messages"
)
func RemoveVoucher(g *CartGrain, m *messages.RemoveVoucher) error {
if m == nil {
return &actor.MutationError{
Message: "RemoveVoucher: nil payload",
Code: 1003,
StatusCode: 400,
}
}
if !slices.ContainsFunc(g.Vouchers, func(v *Voucher) bool {
return v.Id == m.Id
}) {
return &actor.MutationError{
Message: "voucher not applied",
Code: 1004,
StatusCode: 400,
}
}
g.Vouchers = slices.DeleteFunc(g.Vouchers, func(v *Voucher) bool {
return v.Id == m.Id
})
g.UpdateTotals()
return nil
}
func AddVoucher(g *CartGrain, m *messages.AddVoucher) error {
if m == nil {
return &actor.MutationError{
Message: "AddVoucher: nil payload",
Code: 1001,
StatusCode: 400,
}
}
if slices.ContainsFunc(g.Vouchers, func(v *Voucher) bool {
return v.Code == m.Code
}) {
return &actor.MutationError{
Message: "voucher already applied",
Code: 1002,
StatusCode: 400,
}
}
g.lastVoucherId++
g.Vouchers = append(g.Vouchers, &Voucher{
Id: g.lastVoucherId,
Code: m.Code,
Rules: m.VoucherRules,
Value: m.Value,
})
g.UpdateTotals()
return nil
}

View File

@@ -3,7 +3,7 @@ package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/proto"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_change_quantity.go
@@ -25,17 +25,15 @@ import (
// 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 {
func ChangeQuantity(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) {
if it.Id == uint32(m.Id) {
foundIndex = i
break
}
@@ -51,8 +49,6 @@ func init() {
}
g.Items[foundIndex].Quantity = int(m.Quantity)
g.UpdateTotals()
return nil
},
WithTotals(),
)
}

View File

@@ -3,7 +3,7 @@ package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/proto"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_initialize_checkout.go
@@ -28,10 +28,8 @@ import (
// 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 {
func InitializeCheckout(g *CartGrain, m *messages.InitializeCheckout) error {
if m == nil {
return fmt.Errorf("InitializeCheckout: nil payload")
}
@@ -43,7 +41,4 @@ func init() {
g.PaymentStatus = m.Status
g.PaymentInProgress = m.PaymentInProgress
return nil
},
// No WithTotals(): monetary aggregates are unaffected.
)
}

View File

@@ -3,7 +3,7 @@ package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/proto"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_order_created.go
@@ -32,10 +32,8 @@ import (
// - 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 {
func OrderCreated(g *CartGrain, m *messages.OrderCreated) error {
if m == nil {
return fmt.Errorf("OrderCreated: nil payload")
}
@@ -47,7 +45,4 @@ func init() {
g.PaymentStatus = m.Status
g.PaymentInProgress = false
return nil
},
// No WithTotals(): order completion does not modify pricing or taxes.
)
}

View File

@@ -3,7 +3,7 @@ package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/proto"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_remove_delivery.go
@@ -25,14 +25,12 @@ import (
// 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 {
func RemoveDelivery(g *CartGrain, m *messages.RemoveDelivery) error {
if m == nil {
return fmt.Errorf("RemoveDelivery: nil payload")
}
targetID := int(m.Id)
targetID := uint32(m.Id)
index := -1
for i, d := range g.Deliveries {
if d.Id == targetID {
@@ -46,8 +44,6 @@ func init() {
// Remove delivery (order not preserved beyond necessity)
g.Deliveries = append(g.Deliveries[:index], g.Deliveries[index+1:]...)
g.UpdateTotals()
return nil
},
WithTotals(),
)
}

View File

@@ -3,7 +3,7 @@ package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/proto"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_remove_item.go
@@ -21,14 +21,12 @@ import (
// 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 {
func RemoveItem(g *CartGrain, m *messages.RemoveItem) error {
if m == nil {
return fmt.Errorf("RemoveItem: nil payload")
}
targetID := int(m.Id)
targetID := uint32(m.Id)
index := -1
for i, it := range g.Items {
@@ -42,8 +40,6 @@ func init() {
}
g.Items = append(g.Items[:index], g.Items[index+1:]...)
g.UpdateTotals()
return nil
},
WithTotals(),
)
}

View File

@@ -4,7 +4,7 @@ import (
"fmt"
"slices"
messages "git.tornberg.me/go-cart-actor/proto"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_set_delivery.go
@@ -39,10 +39,8 @@ import (
// - 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 {
func SetDelivery(g *CartGrain, m *messages.SetDelivery) error {
if m == nil {
return fmt.Errorf("SetDelivery: nil payload")
}
@@ -51,7 +49,7 @@ func init() {
}
withDelivery := g.ItemsWithDelivery()
targetItems := make([]int, 0)
targetItems := make([]uint32, 0)
if len(m.Items) == 0 {
// Use every item currently without a delivery
@@ -59,7 +57,7 @@ func init() {
} else {
// Validate explicit list
for _, id64 := range m.Items {
id := int(id64)
id := uint32(id64)
found := false
for _, it := range g.Items {
if it.Id == id {
@@ -89,13 +87,10 @@ func init() {
Id: newId,
Provider: m.Provider,
PickupPoint: m.PickupPoint,
Price: 4900, // TODO: externalize pricing
Price: *NewPriceFromIncVat(4900, 25.0),
Items: targetItems,
})
g.mu.Unlock()
return nil
},
WithTotals(),
)
}

View File

@@ -3,7 +3,7 @@ package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/proto"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_set_pickup_point.go
@@ -28,16 +28,14 @@ import (
// - 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 {
func SetPickupPoint(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) {
if d.Id == uint32(m.DeliveryId) {
d.PickupPoint = &messages.PickupPoint{
Id: m.Id,
Name: m.Name,
@@ -50,7 +48,15 @@ func init() {
}
}
return fmt.Errorf("SetPickupPoint: delivery id %d not found", m.DeliveryId)
},
// No WithTotals(): pickup point does not change pricing / tax.
)
}
func ClearCart(g *CartGrain, m *messages.ClearCartRequest) error {
if m == nil {
return fmt.Errorf("ClearCart: nil payload")
}
// maybe check if payment is done?
g.Deliveries = g.Deliveries[:0]
g.Items = g.Items[:0]
g.UpdateTotals()
return nil
}

677
cmd/cart/openapi.json Normal file
View File

@@ -0,0 +1,677 @@
{
"openapi": "3.0.3",
"info": {
"title": "Cart Service API",
"description": "HTTP API for shopping cart operations (cookie-based or explicit id): retrieve cart, add/replace items, update quantity, manage deliveries.",
"version": "1.0.0"
},
"servers": [
{
"url": "https://cart.tornberg.me",
"description": "Production server"
},
{
"url": "http://localhost:8080",
"description": "Local development (cart API mounted under /cart)"
}
],
"paths": {
"/cart/": {
"get": {
"summary": "Get (or create) current cart (cookie based)",
"description": "Returns the current cart. If no cartid cookie is present a new cart is created and Set-Cart-Id response header plus a Set-Cookie header are sent.",
"responses": {
"200": {
"description": "Cart retrieved",
"headers": {
"Set-Cart-Id": {
"description": "Returned when a new cart was created this request",
"schema": { "type": "string" }
},
"X-Pod-Name": {
"description": "Pod identifier serving the request",
"schema": { "type": "string" }
}
},
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"500": { "description": "Server error" }
}
},
"post": {
"summary": "Add single SKU (body)",
"description": "Adds (or increases quantity of) a single SKU using request body.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/AddRequest" }
}
}
},
"responses": {
"200": {
"description": "Item added",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid request body" },
"500": { "description": "Server error" }
}
},
"put": {
"summary": "Change quantity of an item",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ChangeQuantity" }
}
}
},
"responses": {
"200": {
"description": "Quantity updated",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid request body" },
"500": { "description": "Server error" }
}
},
"delete": {
"summary": "Clear cart cookie (logical cart reused only if referenced later)",
"description": "Removes the cartid cookie by expiring it. Does not mutate server-side cart state.",
"responses": {
"200": { "description": "Cookie cleared (empty body)" }
}
}
},
"/cart/add/{sku}": {
"get": {
"summary": "Add a SKU (path)",
"description": "Adds a single SKU with implicit quantity 1. Country inferred from Host header (-se / -no).",
"parameters": [{ "$ref": "#/components/parameters/SkuParam" }],
"responses": {
"200": {
"description": "Item added",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"500": { "description": "Server error" }
}
}
},
"/cart/add": {
"post": {
"summary": "Add multiple items (append)",
"description": "Adds multiple items to the cart without clearing existing contents.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/SetCartItems" }
}
}
},
"responses": {
"200": {
"description": "Items added",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body" },
"500": { "description": "Server error" }
}
}
},
"/cart/set": {
"post": {
"summary": "Replace cart contents",
"description": "Clears the cart first, then adds the provided items (idempotent with respect to target set).",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/SetCartItems" }
}
}
},
"responses": {
"200": {
"description": "Cart replaced",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body" },
"500": { "description": "Server error" }
}
}
},
"/cart/{itemId}": {
"delete": {
"summary": "Remove item by line id",
"parameters": [
{
"name": "itemId",
"in": "path",
"required": true,
"schema": { "type": "integer", "format": "int64", "minimum": 0 },
"description": "Internal cart line item identifier (not SKU)."
}
],
"responses": {
"200": {
"description": "Item removed",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Bad id" },
"500": { "description": "Server error" }
}
}
},
"/cart/delivery": {
"post": {
"summary": "Set (add) delivery",
"description": "Adds a delivery option referencing one or more line item ids.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/SetDeliveryRequest" }
}
}
},
"responses": {
"200": {
"description": "Delivery added/updated",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body" },
"500": { "description": "Server error" }
}
}
},
"/cart/delivery/{deliveryId}": {
"delete": {
"summary": "Remove delivery",
"parameters": [{ "$ref": "#/components/parameters/DeliveryIdParam" }],
"responses": {
"200": {
"description": "Delivery removed",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Bad id" },
"500": { "description": "Server error" }
}
}
},
"/cart/delivery/{deliveryId}/pickupPoint": {
"put": {
"summary": "Set pickup point for delivery",
"parameters": [{ "$ref": "#/components/parameters/DeliveryIdParam" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/PickupPoint" }
}
}
},
"responses": {
"200": {
"description": "Pickup point set",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body" },
"500": { "description": "Server error" }
}
}
},
"/cart/byid/{id}": {
"get": {
"summary": "Get cart by explicit id",
"parameters": [{ "$ref": "#/components/parameters/CartIdParam" }],
"responses": {
"200": {
"description": "Cart retrieved",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid id" },
"500": { "description": "Server error" }
}
},
"post": {
"summary": "Add single SKU (body) by cart id",
"parameters": [{ "$ref": "#/components/parameters/CartIdParam" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/AddRequest" }
}
}
},
"responses": {
"200": {
"description": "Item added",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body" },
"500": { "description": "Server error" }
}
},
"put": {
"summary": "Change quantity (by id variant)",
"parameters": [{ "$ref": "#/components/parameters/CartIdParam" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ChangeQuantity" }
}
}
},
"responses": {
"200": {
"description": "Quantity updated",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body" },
"500": { "description": "Server error" }
}
}
},
"/cart/byid/{id}/add/{sku}": {
"get": {
"summary": "Add SKU (path) by explicit cart id",
"parameters": [
{ "$ref": "#/components/parameters/CartIdParam" },
{ "$ref": "#/components/parameters/SkuParam" }
],
"responses": {
"200": {
"description": "Item added",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid id/sku" },
"500": { "description": "Server error" }
}
}
},
"/cart/byid/{id}/{itemId}": {
"delete": {
"summary": "Remove item (by id variant)",
"parameters": [
{ "$ref": "#/components/parameters/CartIdParam" },
{
"name": "itemId",
"in": "path",
"required": true,
"schema": { "type": "integer", "format": "int64", "minimum": 0 }
}
],
"responses": {
"200": {
"description": "Item removed",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid id" },
"500": { "description": "Server error" }
}
}
},
"/cart/byid/{id}/delivery": {
"post": {
"summary": "Set delivery (by id variant)",
"parameters": [{ "$ref": "#/components/parameters/CartIdParam" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/SetDeliveryRequest" }
}
}
},
"responses": {
"200": {
"description": "Delivery added/updated",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body" },
"500": { "description": "Server error" }
}
}
},
"/cart/byid/{id}/delivery/{deliveryId}": {
"delete": {
"summary": "Remove delivery (by id variant)",
"parameters": [
{ "$ref": "#/components/parameters/CartIdParam" },
{ "$ref": "#/components/parameters/DeliveryIdParam" }
],
"responses": {
"200": {
"description": "Delivery removed",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid ids" },
"500": { "description": "Server error" }
}
}
},
"/cart/byid/{id}/delivery/{deliveryId}/pickupPoint": {
"put": {
"summary": "Set pickup point (by id variant)",
"parameters": [
{ "$ref": "#/components/parameters/CartIdParam" },
{ "$ref": "#/components/parameters/DeliveryIdParam" }
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/PickupPoint" }
}
}
},
"responses": {
"200": {
"description": "Pickup point updated",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body" },
"500": { "description": "Server error" }
}
}
},
"/healthz": {
"get": {
"summary": "Liveness & capacity probe",
"responses": {
"200": { "description": "Healthy" },
"500": { "description": "Unhealthy" }
}
}
},
"/readyz": {
"get": {
"summary": "Readiness probe",
"responses": {
"200": { "description": "Ready" }
}
}
},
"/livez": {
"get": {
"summary": "Liveness probe",
"responses": {
"200": { "description": "Alive" }
}
}
},
"/version": {
"get": {
"summary": "Service version",
"responses": {
"200": {
"description": "Version string",
"content": {
"text/plain": {
"schema": { "type": "string", "example": "1.0.0" }
}
}
}
}
}
}
},
"components": {
"parameters": {
"SkuParam": {
"name": "sku",
"in": "path",
"required": true,
"schema": { "type": "string" }
},
"CartIdParam": {
"name": "id",
"in": "path",
"required": true,
"description": "Base62 encoded cart id",
"schema": {
"type": "string",
"pattern": "^[0-9A-Za-z]+$",
"minLength": 1
}
},
"DeliveryIdParam": {
"name": "deliveryId",
"in": "path",
"required": true,
"schema": { "type": "integer", "format": "int64", "minimum": 0 }
}
},
"schemas": {
"CartGrain": {
"type": "object",
"description": "Cart aggregate (actor state)",
"properties": {
"id": {
"type": "string",
"description": "Cart id (base62 encoded uint64)"
},
"items": {
"type": "array",
"items": { "$ref": "#/components/schemas/CartItem" }
},
"totalPrice": { "type": "integer", "format": "int64" },
"totalTax": { "type": "integer", "format": "int64" },
"totalDiscount": { "type": "integer", "format": "int64" },
"deliveries": {
"type": "array",
"items": { "$ref": "#/components/schemas/CartDelivery" }
},
"processing": { "type": "boolean" },
"paymentInProgress": { "type": "boolean" },
"orderReference": { "type": "string" },
"paymentStatus": { "type": "string" }
},
"required": ["id", "items", "totalPrice", "totalTax", "totalDiscount"]
},
"CartItem": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"itemId": { "type": "integer" },
"parentId": { "type": "integer" },
"sku": { "type": "string" },
"name": { "type": "string" },
"price": { "type": "integer", "format": "int64" },
"totalPrice": { "type": "integer", "format": "int64" },
"totalTax": { "type": "integer", "format": "int64" },
"orgPrice": { "type": "integer", "format": "int64" },
"stock": {
"type": "integer",
"description": "0=OutOfStock,1=LowStock,2=InStock"
},
"qty": { "type": "integer" },
"tax": { "type": "integer" },
"taxRate": { "type": "integer" },
"brand": { "type": "string" },
"category": { "type": "string" },
"category2": { "type": "string" },
"category3": { "type": "string" },
"category4": { "type": "string" },
"category5": { "type": "string" },
"disclaimer": { "type": "string" },
"sellerId": { "type": "string" },
"sellerName": { "type": "string" },
"type": { "type": "string", "description": "Article type" },
"image": { "type": "string" },
"outlet": { "type": "string", "nullable": true },
"storeId": { "type": "string", "nullable": true }
},
"required": ["id", "sku", "name", "price", "qty", "tax"]
},
"CartDelivery": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"provider": { "type": "string" },
"price": { "type": "integer", "format": "int64" },
"items": {
"type": "array",
"items": { "type": "integer" }
},
"pickupPoint": { "$ref": "#/components/schemas/PickupPoint" }
},
"required": ["id", "provider", "price", "items"]
},
"PickupPoint": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string", "nullable": true },
"address": { "type": "string", "nullable": true },
"city": { "type": "string", "nullable": true },
"zip": { "type": "string", "nullable": true },
"country": { "type": "string", "nullable": true }
},
"required": ["id"]
},
"AddRequest": {
"type": "object",
"properties": {
"quantity": {
"type": "integer",
"format": "int32",
"minimum": 1,
"default": 1
},
"sku": { "type": "string" },
"country": {
"type": "string",
"description": "Two-letter country code (inferred if omitted)"
},
"storeId": { "type": "string", "nullable": true }
},
"required": ["sku"]
},
"ChangeQuantity": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int64",
"description": "Cart line item id"
},
"quantity": { "type": "integer", "format": "int32", "minimum": 0 }
},
"required": ["id", "quantity"]
},
"Item": {
"type": "object",
"properties": {
"sku": { "type": "string" },
"quantity": { "type": "integer", "minimum": 1 },
"storeId": { "type": "string", "nullable": true }
},
"required": ["sku", "quantity"]
},
"SetCartItems": {
"type": "object",
"properties": {
"country": { "type": "string" },
"items": {
"type": "array",
"items": { "$ref": "#/components/schemas/Item" },
"minItems": 0
}
},
"required": ["items"]
},
"SetDeliveryRequest": {
"type": "object",
"properties": {
"provider": { "type": "string" },
"items": {
"type": "array",
"items": { "type": "integer", "format": "int64" },
"description": "Line item ids served by this delivery"
},
"pickupPoint": { "$ref": "#/components/schemas/PickupPoint" }
},
"required": ["provider", "items"]
}
}
},
"tags": [{ "name": "Cart" }, { "name": "Delivery" }, { "name": "System" }]
}

69
cmd/cart/openapi_embed.go Normal file
View File

@@ -0,0 +1,69 @@
package main
import (
"bytes"
"crypto/sha256"
_ "embed"
"encoding/hex"
"net/http"
"sync"
)
// openapi_embed.go: Provides embedded OpenAPI spec and helper to mount handler.
//go:embed openapi.json
var openapiJSON []byte
var (
openapiOnce sync.Once
openapiETag string
)
// initOpenAPIMetadata computes immutable metadata for the embedded spec.
func initOpenAPIMetadata() {
sum := sha256.Sum256(openapiJSON)
openapiETag = `W/"` + hex.EncodeToString(sum[:8]) + `"` // weak ETag with first 8 bytes
}
// ServeEmbeddedOpenAPI serves the embedded OpenAPI JSON spec at /openapi.json.
// It supports GET and HEAD and implements basic ETag caching.
func ServeEmbeddedOpenAPI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
openapiOnce.Do(initOpenAPIMetadata)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("ETag", openapiETag)
if match := r.Header.Get("If-None-Match"); match != "" {
if bytes.Contains([]byte(match), []byte(openapiETag)) {
w.WriteHeader(http.StatusNotModified)
return
}
}
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(openapiJSON)
}
// Optional: function to access raw spec bytes programmatically.
func OpenAPISpecBytes() []byte {
return openapiJSON
}
// Optional: function to access current ETag.
func OpenAPIETag() string {
openapiOnce.Do(initOpenAPIMetadata)
return openapiETag
}

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

@@ -0,0 +1,532 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"sync"
"time"
"git.tornberg.me/go-cart-actor/pkg/actor"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"git.tornberg.me/go-cart-actor/pkg/voucher"
"github.com/gogo/protobuf/proto"
)
type PoolServer struct {
actor.GrainPool[*CartGrain]
pod_name string
klarnaClient *KlarnaClient
}
func NewPoolServer(pool actor.GrainPool[*CartGrain], pod_name string, klarnaClient *KlarnaClient) *PoolServer {
return &PoolServer{
GrainPool: pool,
pod_name: pod_name,
klarnaClient: klarnaClient,
}
}
func (s *PoolServer) ApplyLocal(id CartId, mutation ...proto.Message) (*actor.MutationResult[*CartGrain], error) {
return s.Apply(uint64(id), mutation...)
}
func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
grain, err := s.Get(uint64(id))
if err != nil {
return err
}
return s.WriteResult(w, grain)
}
func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
sku := r.PathValue("sku")
msg, err := GetItemAddMessage(sku, 1, getCountryFromHost(r.Host), nil)
if err != nil {
return err
}
data, err := s.ApplyLocal(id, msg)
if err != nil {
return err
}
return s.WriteResult(w, data)
}
func (s *PoolServer) WriteResult(w http.ResponseWriter, result any) error {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("X-Pod-Name", s.pod_name)
if result == nil {
w.WriteHeader(http.StatusInternalServerError)
return nil
}
w.WriteHeader(http.StatusOK)
enc := json.NewEncoder(w)
err := enc.Encode(result)
return err
}
func (s *PoolServer) DeleteItemHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
itemIdString := r.PathValue("itemId")
itemId, err := strconv.ParseInt(itemIdString, 10, 64)
if err != nil {
return err
}
data, err := s.ApplyLocal(id, &messages.RemoveItem{Id: uint32(itemId)})
if err != nil {
return err
}
return s.WriteResult(w, data)
}
type SetDeliveryRequest struct {
Provider string `json:"provider"`
Items []uint32 `json:"items"`
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
}
func (s *PoolServer) SetDeliveryHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
delivery := SetDeliveryRequest{}
err := json.NewDecoder(r.Body).Decode(&delivery)
if err != nil {
return err
}
data, err := s.ApplyLocal(id, &messages.SetDelivery{
Provider: delivery.Provider,
Items: delivery.Items,
PickupPoint: delivery.PickupPoint,
})
if err != nil {
return err
}
return s.WriteResult(w, data)
}
func (s *PoolServer) SetPickupPointHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
deliveryIdString := r.PathValue("deliveryId")
deliveryId, err := strconv.ParseInt(deliveryIdString, 10, 64)
if err != nil {
return err
}
pickupPoint := messages.PickupPoint{}
err = json.NewDecoder(r.Body).Decode(&pickupPoint)
if err != nil {
return err
}
reply, err := s.ApplyLocal(id, &messages.SetPickupPoint{
DeliveryId: uint32(deliveryId),
Id: pickupPoint.Id,
Name: pickupPoint.Name,
Address: pickupPoint.Address,
City: pickupPoint.City,
Zip: pickupPoint.Zip,
Country: pickupPoint.Country,
})
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) RemoveDeliveryHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
deliveryIdString := r.PathValue("deliveryId")
deliveryId, err := strconv.Atoi(deliveryIdString)
if err != nil {
return err
}
reply, err := s.ApplyLocal(id, &messages.RemoveDelivery{Id: uint32(deliveryId)})
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) QuantityChangeHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
changeQuantity := messages.ChangeQuantity{}
err := json.NewDecoder(r.Body).Decode(&changeQuantity)
if err != nil {
return err
}
reply, err := s.ApplyLocal(id, &changeQuantity)
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
type Item struct {
Sku string `json:"sku"`
Quantity int `json:"quantity"`
StoreId *string `json:"storeId,omitempty"`
}
type SetCartItems struct {
Country string `json:"country"`
Items []Item `json:"items"`
}
func getMultipleAddMessages(items []Item, country string) []proto.Message {
wg := sync.WaitGroup{}
mu := sync.Mutex{}
msgs := make([]proto.Message, 0, len(items))
for _, itm := range items {
wg.Go(
func() {
msg, err := GetItemAddMessage(itm.Sku, itm.Quantity, country, itm.StoreId)
if err != nil {
log.Printf("error adding item %s: %v", itm.Sku, err)
return
}
mu.Lock()
msgs = append(msgs, msg)
mu.Unlock()
})
}
wg.Wait()
return msgs
}
func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
setCartItems := SetCartItems{}
err := json.NewDecoder(r.Body).Decode(&setCartItems)
if err != nil {
return err
}
msgs := make([]proto.Message, 0, len(setCartItems.Items)+1)
msgs = append(msgs, &messages.ClearCartRequest{})
msgs = append(msgs, getMultipleAddMessages(setCartItems.Items, setCartItems.Country)...)
reply, err := s.ApplyLocal(id, msgs...)
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) AddMultipleItemHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
setCartItems := SetCartItems{}
err := json.NewDecoder(r.Body).Decode(&setCartItems)
if err != nil {
return err
}
reply, err := s.ApplyLocal(id, getMultipleAddMessages(setCartItems.Items, setCartItems.Country)...)
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
type AddRequest struct {
Sku string `json:"sku"`
Quantity int32 `json:"quantity"`
Country string `json:"country"`
StoreId *string `json:"storeId"`
}
func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
addRequest := AddRequest{Quantity: 1}
err := json.NewDecoder(r.Body).Decode(&addRequest)
if err != nil {
return err
}
msg, err := GetItemAddMessage(addRequest.Sku, int(addRequest.Quantity), addRequest.Country, addRequest.StoreId)
if err != nil {
return err
}
reply, err := s.ApplyLocal(id, msg)
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
// func (s *PoolServer) HandleConfirmation(w http.ResponseWriter, r *http.Request, id CartId) error {
// orderId := r.PathValue("orderId")
// if orderId == "" {
// return fmt.Errorf("orderId is empty")
// }
// order, err := KlarnaInstance.GetOrder(orderId)
// if err != nil {
// return err
// }
// w.Header().Set("Content-Type", "application/json")
// w.Header().Set("X-Pod-Name", s.pod_name)
// w.Header().Set("Cache-Control", "no-cache")
// w.Header().Set("Access-Control-Allow-Origin", "*")
// w.WriteHeader(http.StatusOK)
// return json.NewEncoder(w).Encode(order)
// }
func getCurrency(country string) string {
if country == "no" {
return "NOK"
}
return "SEK"
}
func getLocale(country string) string {
if country == "no" {
return "nb-no"
}
return "sv-se"
}
func (s *PoolServer) CreateOrUpdateCheckout(host string, id CartId) (*CheckoutOrder, error) {
country := getCountryFromHost(host)
meta := &CheckoutMeta{
Terms: fmt.Sprintf("https://%s/terms", host),
Checkout: fmt.Sprintf("https://%s/checkout?order_id={checkout.order.id}", host),
Confirmation: fmt.Sprintf("https://%s/confirmation/{checkout.order.id}", host),
Validation: fmt.Sprintf("https://%s/validate", host),
Push: fmt.Sprintf("https://%s/push?order_id={checkout.order.id}", host),
Country: country,
Currency: getCurrency(country),
Locale: getLocale(country),
}
// Get current grain state (may be local or remote)
grain, err := s.Get(uint64(id))
if err != nil {
return nil, err
}
// Build pure checkout payload
payload, _, err := BuildCheckoutOrderPayload(grain, meta)
if err != nil {
return nil, err
}
if grain.OrderReference != "" {
return s.klarnaClient.UpdateOrder(grain.OrderReference, bytes.NewReader(payload))
} else {
return s.klarnaClient.CreateOrder(bytes.NewReader(payload))
}
}
func (s *PoolServer) ApplyCheckoutStarted(klarnaOrder *CheckoutOrder, id CartId) (*actor.MutationResult[*CartGrain], error) {
// Persist initialization state via mutation (best-effort)
return s.ApplyLocal(id, &messages.InitializeCheckout{
OrderId: klarnaOrder.ID,
Status: klarnaOrder.Status,
PaymentInProgress: true,
})
}
// func (s *PoolServer) HandleCheckout(w http.ResponseWriter, r *http.Request, id CartId) error {
// klarnaOrder, err := s.CreateOrUpdateCheckout(r.Host, id)
// if err != nil {
// return err
// }
// s.ApplyCheckoutStarted(klarnaOrder, id)
// w.Header().Set("Content-Type", "application/json")
// return json.NewEncoder(w).Encode(klarnaOrder)
// }
//
func CookieCartIdHandler(fn func(cartId 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.OwnerHost(uint64(cartId)); ok {
handled, err := ownerHost.Proxy(uint64(cartId), w, r)
if err == nil && handled {
return nil
}
}
return fn(w, r, cartId)
}
}
type AddVoucherRequest struct {
VoucherCode string `json:"code"`
}
func (s *PoolServer) AddVoucherHandler(w http.ResponseWriter, r *http.Request, cartId CartId) error {
data := &AddVoucherRequest{}
json.NewDecoder(r.Body).Decode(data)
v := voucher.Service{}
msg, err := v.GetVoucher(data.VoucherCode)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return err
}
reply, err := s.ApplyLocal(cartId, msg)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return err
}
s.WriteResult(w, reply)
return nil
}
func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request, cartId CartId) error {
idStr := r.PathValue("voucherId")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return err
}
reply, err := s.ApplyLocal(cartId, &messages.RemoveVoucher{Id: uint32(id)})
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return err
}
s.WriteResult(w, reply)
return nil
}
func (s *PoolServer) Serve() *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.GetCartHandler)))
mux.HandleFunc("GET /add/{sku}", CookieCartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler)))
mux.HandleFunc("POST /add", CookieCartIdHandler(s.ProxyHandler(s.AddMultipleItemHandler)))
mux.HandleFunc("POST /", CookieCartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
mux.HandleFunc("POST /set", CookieCartIdHandler(s.ProxyHandler(s.SetCartItemsHandler)))
mux.HandleFunc("DELETE /{itemId}", CookieCartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
mux.HandleFunc("PUT /", CookieCartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
mux.HandleFunc("DELETE /", CookieCartIdHandler(s.ProxyHandler(s.RemoveCartCookie)))
mux.HandleFunc("POST /delivery", CookieCartIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
mux.HandleFunc("DELETE /delivery/{deliveryId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
mux.HandleFunc("PUT /delivery/{deliveryId}/pickupPoint", CookieCartIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
mux.HandleFunc("PUT /voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
mux.HandleFunc("DELETE /voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
//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.GetCartHandler)))
mux.HandleFunc("GET /byid/{id}/add/{sku}", CartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler)))
mux.HandleFunc("POST /byid/{id}", CartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
mux.HandleFunc("DELETE /byid/{id}/{itemId}", CartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
mux.HandleFunc("PUT /byid/{id}", CartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
mux.HandleFunc("POST /byid/{id}/delivery", CartIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
mux.HandleFunc("DELETE /byid/{id}/delivery/{deliveryId}", CartIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
mux.HandleFunc("PUT /byid/{id}/delivery/{deliveryId}/pickupPoint", CartIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
mux.HandleFunc("PUT /byid/{id}/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
mux.HandleFunc("DELETE /byid/{id}/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
//mux.HandleFunc("GET /byid/{id}/checkout", CartIdHandler(s.ProxyHandler(s.HandleCheckout)))
//mux.HandleFunc("GET /byid/{id}/confirmation", CartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
return mux
}

131
cmd/cart/price.go Normal file
View File

@@ -0,0 +1,131 @@
package main
import (
"encoding/json"
"strconv"
)
func GetTaxAmount(total int64, tax int) int64 {
taxD := 10000 / float64(tax)
return int64(float64(total) / float64((1 + taxD)))
}
type Price struct {
IncVat int64 `json:"incVat"`
VatRates map[float32]int64 `json:"vat,omitempty"`
}
func NewPrice() *Price {
return &Price{
IncVat: 0,
VatRates: make(map[float32]int64),
}
}
func NewPriceFromIncVat(incVat int64, taxRate float32) *Price {
tax := GetTaxAmount(incVat, int(taxRate*100))
return &Price{
IncVat: incVat,
VatRates: map[float32]int64{
taxRate: tax,
},
}
}
func (p *Price) ValueExVat() int64 {
exVat := p.IncVat
for _, amount := range p.VatRates {
exVat -= amount
}
return exVat
}
func (p *Price) TotalVat() int64 {
total := int64(0)
for _, amount := range p.VatRates {
total += amount
}
return total
}
func MultiplyPrice(p Price, qty int64) *Price {
ret := &Price{
IncVat: p.IncVat * qty,
VatRates: make(map[float32]int64),
}
for rate, amount := range p.VatRates {
ret.VatRates[rate] = amount * qty
}
return ret
}
func (p *Price) Multiply(qty int64) {
p.IncVat *= qty
for rate, amount := range p.VatRates {
p.VatRates[rate] = amount * qty
}
}
func (p Price) MarshalJSON() ([]byte, error) {
// Build a stable wire format without calling Price.MarshalJSON recursively
exVat := p.ValueExVat()
var vat map[string]int64
if len(p.VatRates) > 0 {
vat = make(map[string]int64, len(p.VatRates))
for rate, amount := range p.VatRates {
// Rely on default formatting that trims trailing zeros for whole numbers
// Using %g could output scientific notation for large numbers; float32 rates here are small.
key := trimFloat(rate)
vat[key] = amount
}
}
type wire struct {
ExVat int64 `json:"exVat"`
IncVat int64 `json:"incVat"`
Vat map[string]int64 `json:"vat,omitempty"`
}
return json.Marshal(wire{ExVat: exVat, IncVat: p.IncVat, Vat: vat})
}
// trimFloat converts a float32 tax rate like 25 or 12.5 into a compact string without
// unnecessary decimals ("25", "12.5").
func trimFloat(f float32) string {
// Convert via FormatFloat then trim trailing zeros and dot.
s := strconv.FormatFloat(float64(f), 'f', -1, 32)
return s
}
func (p *Price) Add(price Price) {
p.IncVat += price.IncVat
for rate, amount := range price.VatRates {
p.VatRates[rate] += amount
}
}
func (p *Price) Subtract(price Price) {
p.IncVat -= price.IncVat
for rate, amount := range price.VatRates {
p.VatRates[rate] -= amount
}
}
func SumPrices(prices ...Price) *Price {
if len(prices) == 0 {
return NewPrice()
}
aggregated := NewPrice()
for _, price := range prices {
aggregated.IncVat += price.IncVat
for rate, amount := range price.VatRates {
aggregated.VatRates[rate] += amount
}
}
if len(aggregated.VatRates) == 0 {
aggregated.VatRates = nil
}
return aggregated
}

135
cmd/cart/price_test.go Normal file
View File

@@ -0,0 +1,135 @@
package main
import (
"encoding/json"
"testing"
)
func TestPriceMarshalJSON(t *testing.T) {
p := Price{IncVat: 13700, VatRates: map[float32]int64{25: 2500, 12: 1200}}
// ExVat = 13700 - (2500+1200) = 10000
data, err := json.Marshal(p)
if err != nil {
t.Fatalf("marshal error: %v", err)
}
// Unmarshal into a generic struct to validate fields
var out struct {
ExVat int64 `json:"exVat"`
IncVat int64 `json:"incVat"`
Vat map[string]int64 `json:"vat"`
}
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if out.ExVat != 10000 {
t.Fatalf("expected exVat 10000 got %d", out.ExVat)
}
if out.IncVat != 13700 {
t.Fatalf("expected incVat 13700 got %d", out.IncVat)
}
if out.Vat["25"] != 2500 || out.Vat["12"] != 1200 {
t.Fatalf("unexpected vat map: %#v", out.Vat)
}
}
func TestNewPriceFromIncVat(t *testing.T) {
p := NewPriceFromIncVat(1000, 0.25)
if p.IncVat != 1000 {
t.Fatalf("expected IncVat %d got %d", 1000, p.IncVat)
}
if p.VatRates[25] != 250 {
t.Fatalf("expected VAT 25 rate %d got %d", 250, p.VatRates[25])
}
if p.ValueExVat() != 750 {
t.Fatalf("expected exVat %d got %d", 750, p.ValueExVat())
}
}
func TestSumPrices(t *testing.T) {
// We'll construct prices via raw struct since constructor expects tax math.
// IncVat already includes vat portions.
a := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}} // ex=1000
b := Price{IncVat: 2740, VatRates: map[float32]int64{25: 500, 12: 240}} // ex=2000
c := Price{IncVat: 0, VatRates: nil}
sum := SumPrices(a, b, c)
if sum.IncVat != 3990 { // 1250+2740
t.Fatalf("expected incVat 3990 got %d", sum.IncVat)
}
if len(sum.VatRates) != 2 {
t.Fatalf("expected 2 vat rates got %d", len(sum.VatRates))
}
if sum.VatRates[25] != 750 {
t.Fatalf("expected 25%% vat 750 got %d", sum.VatRates[25])
}
if sum.VatRates[12] != 240 {
t.Fatalf("expected 12%% vat 240 got %d", sum.VatRates[12])
}
if sum.ValueExVat() != 3000 { // 3990 - (750+240)
t.Fatalf("expected exVat 3000 got %d", sum.ValueExVat())
}
}
func TestSumPricesEmpty(t *testing.T) {
sum := SumPrices()
if sum.IncVat != 0 || sum.VatRates == nil { // constructor sets empty map
t.Fatalf("expected zero price got %#v", sum)
}
}
func TestMultiplyPriceFunction(t *testing.T) {
base := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}}
multiplied := MultiplyPrice(base, 3)
if multiplied.IncVat != 1250*3 {
t.Fatalf("expected IncVat %d got %d", 1250*3, multiplied.IncVat)
}
if multiplied.VatRates[25] != 250*3 {
t.Fatalf("expected VAT 25 rate %d got %d", 250*3, multiplied.VatRates[25])
}
if multiplied.ValueExVat() != (1250-250)*3 {
t.Fatalf("expected exVat %d got %d", (1250-250)*3, multiplied.ValueExVat())
}
}
func TestPriceAddSubtract(t *testing.T) {
a := Price{IncVat: 1000, VatRates: map[float32]int64{25: 200}}
b := Price{IncVat: 500, VatRates: map[float32]int64{25: 100, 12: 54}}
acc := NewPrice()
acc.Add(a)
acc.Add(b)
if acc.IncVat != 1500 {
t.Fatalf("expected IncVat 1500 got %d", acc.IncVat)
}
if acc.VatRates[25] != 300 || acc.VatRates[12] != 54 {
t.Fatalf("unexpected VAT map: %#v", acc.VatRates)
}
// Subtract b then a returns to zero
acc.Subtract(b)
acc.Subtract(a)
if acc.IncVat != 0 {
t.Fatalf("expected IncVat 0 got %d", acc.IncVat)
}
if len(acc.VatRates) != 2 || acc.VatRates[25] != 0 || acc.VatRates[12] != 0 {
t.Fatalf("expected zeroed vat rates got %#v", acc.VatRates)
}
}
func TestPriceMultiplyMethod(t *testing.T) {
p := Price{IncVat: 2000, VatRates: map[float32]int64{25: 400}}
// Value before multiply
exBefore := p.ValueExVat()
p.Multiply(2)
if p.IncVat != 4000 {
t.Fatalf("expected IncVat 4000 got %d", p.IncVat)
}
if p.VatRates[25] != 800 {
t.Fatalf("expected VAT 800 got %d", p.VatRates[25])
}
if p.ValueExVat() != exBefore*2 {
t.Fatalf("expected exVat %d got %d", exBefore*2, p.ValueExVat())
}
}

121
cmd/cart/product-fetcher.go Normal file
View File

@@ -0,0 +1,121 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"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
}
func GetItemAddMessage(sku string, qty int, country string, storeId *string) (*messages.AddItem, error) {
item, err := FetchItem(sku, country)
if err != nil {
return nil, err
}
return ToItemAddMessage(item, storeId, qty, country), nil
}
func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country string) *messages.AddItem {
orgPrice, _ := getInt(item.GetNumberFieldValue(5)) // getInt(item.Fields[5])
price, err := getInt(item.GetNumberFieldValue(4)) //Fields[4]
if err != nil {
return nil
}
stock := StockStatus(0)
centralStockValue, ok := item.GetStringFieldValue(3)
if storeId == nil {
if ok {
pureNumber := strings.Replace(centralStockValue, "+", "", -1)
if centralStock, err := strconv.ParseInt(pureNumber, 10, 64); err == nil {
stock = StockStatus(centralStock)
}
}
} else {
storeStock, ok := item.Stock.GetStock()[*storeId]
if ok {
stock = StockStatus(storeStock)
}
}
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: uint32(item.Id),
Quantity: int32(qty),
Price: int64(price),
OrgPrice: int64(orgPrice),
Sku: item.GetSku(),
Name: item.Title,
Image: item.Img,
Stock: int32(stock),
Brand: brand,
Category: category,
Category2: category2,
Category3: category3,
Category4: category4,
Category5: category5,
Tax: getTax(articleType),
SellerId: sellerId,
SellerName: sellerName,
ArticleType: articleType,
Disclaimer: item.Disclaimer,
Country: country,
Outlet: outlet,
StoreId: storeId,
}
}
func getTax(articleType string) int32 {
switch articleType {
case "ZDIE":
return 600
default:
return 2500
}
}

View File

@@ -15,7 +15,7 @@ metadata:
arch: amd64
name: cart-actor-x86
spec:
replicas: 0
replicas: 3
selector:
matchLabels:
app: cart-actor
@@ -113,7 +113,7 @@ metadata:
arch: arm64
name: cart-actor-arm64
spec:
replicas: 3
replicas: 0
selector:
matchLabels:
app: cart-actor

View File

@@ -1,84 +0,0 @@
package main
import (
"fmt"
"log"
"net"
"sync"
"time"
)
type DiscardedHost struct {
Host string
Tries int
}
type DiscardedHostHandler struct {
mu sync.RWMutex
port int
hosts []*DiscardedHost
onConnection *func(string)
}
func (d *DiscardedHostHandler) run() {
for range time.Tick(time.Second) {
d.mu.RLock()
lst := make([]*DiscardedHost, 0, len(d.hosts))
for _, host := range d.hosts {
if host.Tries >= 0 && host.Tries < 5 {
go d.testConnection(host)
lst = append(lst, host)
} else {
if host.Tries > 0 {
log.Printf("Host %s discarded after %d tries", host.Host, host.Tries)
}
}
}
d.mu.RUnlock()
d.mu.Lock()
d.hosts = lst
d.mu.Unlock()
}
}
func (d *DiscardedHostHandler) testConnection(host *DiscardedHost) {
addr := fmt.Sprintf("%s:%d", host.Host, d.port)
conn, err := net.Dial("tcp", addr)
if err != nil {
host.Tries++
if host.Tries >= 5 {
// Exceeded retry threshold; will be dropped by run loop.
}
} else {
conn.Close()
if d.onConnection != nil {
fn := *d.onConnection
fn(host.Host)
}
}
}
func NewDiscardedHostHandler(port int) *DiscardedHostHandler {
ret := &DiscardedHostHandler{
hosts: make([]*DiscardedHost, 0),
port: port,
}
go ret.run()
return ret
}
func (d *DiscardedHostHandler) SetReconnectHandler(fn func(string)) {
d.onConnection = &fn
}
func (d *DiscardedHostHandler) AppendHost(host string) {
d.mu.Lock()
defer d.mu.Unlock()
log.Printf("Adding host %s to retry list", host)
d.hosts = append(d.hosts, &DiscardedHost{
Host: host,
Tries: 0,
})
}

View File

@@ -1,73 +0,0 @@
package main
import (
"encoding/gob"
"time"
)
func init() {
gob.Register(map[uint64]int64{})
}
type DiskStorage struct {
stateFile string
lastSave int64
LastSaves map[uint64]int64
}
func NewDiskStorage(stateFile string) (*DiskStorage, error) {
ret := &DiskStorage{
stateFile: stateFile,
LastSaves: make(map[uint64]int64),
}
//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().Unix()
s.LastSaves[uint64(id)] = ts
s.lastSave = ts
return nil
}

View File

@@ -1,288 +0,0 @@
package main
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"sync"
"time"
messages "git.tornberg.me/go-cart-actor/proto"
"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 int64 `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).
*/

21
go.mod
View File

@@ -3,6 +3,7 @@ module git.tornberg.me/go-cart-actor
go 1.25.1
require (
github.com/gogo/protobuf v1.3.2
github.com/google/uuid v1.6.0
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548
github.com/prometheus/client_golang v1.23.2
@@ -20,8 +21,10 @@ require (
github.com/bits-and-blooms/bitset v1.24.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/getkin/kin-openapi v0.132.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-openapi/jsonpointer v0.22.1 // indirect
github.com/go-openapi/jsonreference v0.21.2 // indirect
@@ -37,32 +40,46 @@ require (
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/gorilla/schema v1.4.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 // indirect
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.1 // indirect
github.com/prometheus/procfs v0.17.0 // indirect
github.com/speakeasy-api/jsonpath v0.6.0 // indirect
github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.28.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/oauth2 v0.32.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.37.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
@@ -71,3 +88,5 @@ require (
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen

118
go.sum
View File

@@ -7,14 +7,25 @@ github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJiz
github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -49,35 +60,62 @@ github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548 h1:lkxVz5lNPlU78El49cx1c3Rxo/bp7Gbzp2BjSRFgg1U=
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548/go.mod h1:k4lo5gFYb3AqrgftlraCnv95xvjB/w8udRqJQJ12mAE=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -86,14 +124,38 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 h1:iJvF8SdB/3/+eGOXEpsWkD8FQAHj6mqkb6Fnsoc8MFU=
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0/go.mod h1:fwlMxUEMuQK5ih9aymrxKPQqNm2n8bdLk1ppjH+lr9w=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
@@ -108,15 +170,27 @@ github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzuk
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g8DHMXJ8=
github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw=
github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU=
github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -144,26 +218,50 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
@@ -171,6 +269,7 @@ golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
@@ -184,15 +283,34 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff h1:
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -1,827 +0,0 @@
package main
import (
"bytes"
"context"
"fmt"
"io"
"log"
"net/http"
"reflect"
"sync"
"time"
messages "git.tornberg.me/go-cart-actor/proto"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"k8s.io/apimachinery/pkg/watch"
)
// ---------------------------------------------------------------------------
// Metrics shared by the cart pool implementation.
// ---------------------------------------------------------------------------
var (
poolGrains = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_grains_in_pool",
Help: "The total number of grains in the local pool",
})
poolSize = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_pool_size",
Help: "Configured capacity of the cart pool",
})
poolUsage = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_grain_pool_usage",
Help: "Current utilisation of the cart pool",
})
negotiationCount = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_remote_negotiation_total",
Help: "The total number of remote host negotiations",
})
connectedRemotes = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_connected_remotes",
Help: "Number of connected remote hosts",
})
cartMutationsTotal = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_mutations_total",
Help: "Total number of cart state mutations applied",
})
cartMutationFailuresTotal = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_mutation_failures_total",
Help: "Total number of failed cart state mutations",
})
cartMutationLatencySeconds = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "cart_mutation_latency_seconds",
Help: "Latency of cart mutations in seconds",
Buckets: prometheus.DefBuckets,
}, []string{"mutation"})
cartActiveGrains = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_active_grains",
Help: "Number of active (resident) local grains",
})
)
// GrainPool is the interface exposed to HTTP handlers and other subsystems.
type GrainPool interface {
Apply(id CartId, mutation interface{}) (*CartGrain, error)
Get(id CartId) (*CartGrain, error)
OwnerHost(id CartId) (Host, bool)
Hostname() string
TakeOwnership(id CartId)
IsHealthy() bool
Close()
}
// Host abstracts a remote node capable of proxying cart requests.
type Host interface {
Name() string
Proxy(id CartId, w http.ResponseWriter, r *http.Request) (bool, error)
}
// Ttl tracks the expiry deadline for an in-memory grain.
type Ttl struct {
Expires time.Time
Grain *CartGrain
}
// CartPool merges the responsibilities that previously belonged to
// GrainLocalPool and SyncedPool. It provides local grain storage together
// with cluster coordination, ownership negotiation and expiry signalling.
type CartPool struct {
// Local grain state -----------------------------------------------------
localMu sync.RWMutex
grains map[uint64]*CartGrain
expiry []Ttl
spawn func(id CartId) (*CartGrain, error)
ttl time.Duration
poolSize int
// Cluster coordination --------------------------------------------------
hostname string
remoteMu sync.RWMutex
remoteOwners map[CartId]*RemoteHostGRPC
remoteHosts map[string]*RemoteHostGRPC
discardedHostHandler *DiscardedHostHandler
// House-keeping ---------------------------------------------------------
purgeTicker *time.Ticker
}
// RemoteHostGRPC mirrors the lightweight controller used for remote node
// interaction.
type RemoteHostGRPC struct {
Host string
HTTPBase string
Conn *grpc.ClientConn
Transport *http.Transport
Client *http.Client
ControlClient messages.ControlPlaneClient
MissedPings int
}
func (h *RemoteHostGRPC) Name() string {
return h.Host
}
func (h *RemoteHostGRPC) Proxy(id CartId, w http.ResponseWriter, r *http.Request) (bool, error) {
target := fmt.Sprintf("%s%s", h.HTTPBase, r.URL.RequestURI())
var bodyCopy []byte
if r.Body != nil && r.Body != http.NoBody {
var err error
bodyCopy, err = io.ReadAll(r.Body)
if err != nil {
http.Error(w, "proxy read error", http.StatusBadGateway)
return false, err
}
}
if r.Body != nil {
r.Body.Close()
}
var reqBody io.Reader
if len(bodyCopy) > 0 {
reqBody = bytes.NewReader(bodyCopy)
}
req, err := http.NewRequestWithContext(r.Context(), r.Method, target, reqBody)
if err != nil {
http.Error(w, "proxy build error", http.StatusBadGateway)
return false, err
}
r.Body = io.NopCloser(bytes.NewReader(bodyCopy))
req.Header.Set("X-Forwarded-Host", r.Host)
if idStr := id.String(); idStr != "" {
req.Header.Set("X-Cart-Id", idStr)
}
for k, v := range r.Header {
for _, vv := range v {
req.Header.Add(k, vv)
}
}
res, err := h.Client.Do(req)
if err != nil {
http.Error(w, "proxy request error", http.StatusBadGateway)
return false, err
}
defer res.Body.Close()
for k, v := range res.Header {
for _, vv := range v {
w.Header().Add(k, vv)
}
}
w.Header().Set("X-Cart-Owner-Routed", "true")
if res.StatusCode >= 200 && res.StatusCode <= 299 {
w.WriteHeader(res.StatusCode)
_, copyErr := io.Copy(w, res.Body)
if copyErr != nil {
return true, copyErr
}
return true, nil
}
return false, fmt.Errorf("proxy response status %d", res.StatusCode)
}
func (r *RemoteHostGRPC) IsHealthy() bool {
return r.MissedPings < 3
}
// NewCartPool constructs a unified pool. Discovery may be nil for standalone
// deployments.
func NewCartPool(size int, ttl time.Duration, hostname string, spawn func(id CartId) (*CartGrain, error), discovery Discovery) (*CartPool, error) {
p := &CartPool{
grains: make(map[uint64]*CartGrain),
expiry: make([]Ttl, 0),
spawn: spawn,
ttl: ttl,
poolSize: size,
hostname: hostname,
remoteOwners: make(map[CartId]*RemoteHostGRPC),
remoteHosts: make(map[string]*RemoteHostGRPC),
}
p.discardedHostHandler = NewDiscardedHostHandler(1338)
p.discardedHostHandler.SetReconnectHandler(p.AddRemote)
p.purgeTicker = time.NewTicker(time.Minute)
go func() {
for range p.purgeTicker.C {
p.Purge()
}
}()
if discovery != nil {
go p.startDiscovery(discovery)
} else {
log.Printf("No discovery configured; expecting manual AddRemote or static host injection")
}
return p, nil
}
// startDiscovery subscribes to cluster events and adds/removes hosts.
func (p *CartPool) startDiscovery(discovery Discovery) {
time.Sleep(3 * time.Second) // allow gRPC server startup
log.Printf("Starting discovery watcher")
ch, err := discovery.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 p.IsKnown(evt.Host) {
p.RemoveHost(evt.Host)
}
default:
if !p.IsKnown(evt.Host) {
log.Printf("Discovered host %s", evt.Host)
p.AddRemote(evt.Host)
}
}
}
}
// ---------------------------------------------------------------------------
// Local grain management
// ---------------------------------------------------------------------------
func (p *CartPool) statsUpdate() {
p.localMu.RLock()
size := len(p.grains)
cap := p.poolSize
p.localMu.RUnlock()
poolGrains.Set(float64(size))
poolSize.Set(float64(cap))
if cap > 0 {
poolUsage.Set(float64(size) / float64(cap))
}
}
// LocalUsage returns the number of resident grains and configured capacity.
func (p *CartPool) LocalUsage() (int, int) {
p.localMu.RLock()
defer p.localMu.RUnlock()
return len(p.grains), p.poolSize
}
// SetAvailable pre-populates placeholder entries.
func (p *CartPool) SetAvailable(availableWithLastChangeUnix map[CartId]int64) {
p.localMu.Lock()
defer p.localMu.Unlock()
for id := range availableWithLastChangeUnix {
k := uint64(id)
if _, ok := p.grains[k]; !ok {
p.grains[k] = nil
p.expiry = append(p.expiry, Ttl{Expires: time.Now().Add(p.ttl)})
}
}
p.statsUpdate()
}
// Purge removes expired grains and broadcasts expiry announcements so that
// other hosts drop stale ownership hints.
func (p *CartPool) Purge() {
now := time.Now()
keepChanged := now.Add(-p.ttl).Unix()
var expired []CartId
p.localMu.Lock()
for i := 0; i < len(p.expiry); {
entry := p.expiry[i]
if entry.Grain == nil {
i++
continue
}
if entry.Expires.After(now) {
break
}
if entry.Grain.GetLastChange() > keepChanged {
// Recently mutated: move to back.
p.expiry = append(p.expiry[:i], p.expiry[i+1:]...)
p.expiry = append(p.expiry, entry)
continue
}
id := entry.Grain.GetId()
delete(p.grains, uint64(id))
expired = append(expired, id)
if i < len(p.expiry)-1 {
p.expiry = append(p.expiry[:i], p.expiry[i+1:]...)
} else {
p.expiry = p.expiry[:i]
}
}
p.localMu.Unlock()
if len(expired) > 0 {
p.statsUpdate()
go p.broadcastExpiry(expired)
}
}
// RefreshExpiry updates the TTL entry for a given grain.
func (p *CartPool) RefreshExpiry(id CartId) {
p.localMu.Lock()
defer p.localMu.Unlock()
for i := range p.expiry {
g := p.expiry[i].Grain
if g != nil && g.Id == id {
p.expiry[i].Expires = time.Now().Add(p.ttl)
return
}
}
// If no entry existed, append one (safeguard for newly spawned grains).
p.expiry = append(p.expiry, Ttl{Expires: time.Now().Add(p.ttl), Grain: p.grains[uint64(id)]})
}
// DebugGrainCount returns the number of locally resident grains.
func (p *CartPool) DebugGrainCount() int {
p.localMu.RLock()
defer p.localMu.RUnlock()
return len(p.grains)
}
// LocalCartIDs returns the currently owned cart ids (for control-plane RPCs).
func (p *CartPool) LocalCartIDs() []uint64 {
p.localMu.RLock()
defer p.localMu.RUnlock()
ids := make([]uint64, 0, len(p.grains))
for _, g := range p.grains {
if g == nil {
continue
}
ids = append(ids, uint64(g.GetId()))
}
return ids
}
// SnapshotGrains returns a copy of the currently resident grains keyed by id.
func (p *CartPool) SnapshotGrains() map[CartId]*CartGrain {
p.localMu.RLock()
defer p.localMu.RUnlock()
out := make(map[CartId]*CartGrain, len(p.grains))
for _, g := range p.grains {
if g != nil {
out[g.GetId()] = g
}
}
return out
}
func (p *CartPool) removeLocalGrain(id CartId) {
p.localMu.Lock()
delete(p.grains, uint64(id))
for i := range p.expiry {
if p.expiry[i].Grain != nil && p.expiry[i].Grain.GetId() == id {
p.expiry = append(p.expiry[:i], p.expiry[i+1:]...)
break
}
}
p.localMu.Unlock()
p.statsUpdate()
}
func (p *CartPool) getLocalGrain(id CartId) (*CartGrain, error) {
key := uint64(id)
grainLookups.Inc()
p.localMu.RLock()
grain, ok := p.grains[key]
p.localMu.RUnlock()
if grain != nil && ok {
return grain, nil
}
p.localMu.Lock()
defer p.localMu.Unlock()
grain, ok = p.grains[key]
if grain == nil || !ok {
if len(p.grains) >= p.poolSize && len(p.expiry) > 0 {
if p.expiry[0].Expires.Before(time.Now()) && p.expiry[0].Grain != nil {
oldID := p.expiry[0].Grain.GetId()
delete(p.grains, uint64(oldID))
p.expiry = p.expiry[1:]
go p.broadcastExpiry([]CartId{oldID})
} else {
return nil, fmt.Errorf("pool is full")
}
}
spawned, err := p.spawn(id)
if err != nil {
return nil, err
}
p.grains[key] = spawned
p.expiry = append(p.expiry, Ttl{Expires: time.Now().Add(p.ttl), Grain: spawned})
grain = spawned
}
go p.statsUpdate()
return grain, nil
}
// ---------------------------------------------------------------------------
// Cluster ownership and coordination
// ---------------------------------------------------------------------------
func (p *CartPool) TakeOwnership(id CartId) {
p.broadcastOwnership([]CartId{id})
}
func (p *CartPool) AddRemote(host string) {
if host == "" || host == p.hostname {
return
}
p.remoteMu.Lock()
if _, exists := p.remoteHosts[host]; exists {
p.remoteMu.Unlock()
return
}
p.remoteMu.Unlock()
target := fmt.Sprintf("%s:1337", host)
dialCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
conn, err := grpc.DialContext(dialCtx, target, grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock())
cancel()
if err != nil {
log.Printf("AddRemote: dial %s failed: %v", target, err)
return
}
controlClient := messages.NewControlPlaneClient(conn)
for retries := 0; retries < 3; retries++ {
ctx, pingCancel := context.WithTimeout(context.Background(), time.Second)
_, pingErr := controlClient.Ping(ctx, &messages.Empty{})
pingCancel()
if pingErr == nil {
break
}
if retries == 2 {
log.Printf("AddRemote: ping %s failed after retries: %v", host, pingErr)
conn.Close()
return
}
time.Sleep(200 * time.Millisecond)
}
transport := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 120 * time.Second,
}
client := &http.Client{Transport: transport, Timeout: 10 * time.Second}
remote := &RemoteHostGRPC{
Host: host,
HTTPBase: fmt.Sprintf("http://%s:8080/cart", host),
Conn: conn,
Transport: transport,
Client: client,
ControlClient: controlClient,
}
p.remoteMu.Lock()
p.remoteHosts[host] = remote
p.remoteMu.Unlock()
connectedRemotes.Set(float64(p.RemoteCount()))
log.Printf("Connected to remote host %s", host)
go p.pingLoop(remote)
go p.initializeRemote(remote)
go p.Negotiate()
}
func (p *CartPool) initializeRemote(remote *RemoteHostGRPC) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
reply, err := remote.ControlClient.GetCartIds(ctx, &messages.Empty{})
if err != nil {
log.Printf("Init remote %s: GetCartIds error: %v", remote.Host, err)
return
}
count := 0
p.remoteMu.Lock()
for _, cid := range reply.CartIds {
id := CartId(cid)
if _, exists := p.remoteOwners[id]; !exists {
p.remoteOwners[id] = remote
}
count++
}
p.remoteMu.Unlock()
log.Printf("Remote %s reported %d remote-owned carts", remote.Host, count)
}
func (p *CartPool) RemoveHost(host string) {
p.remoteMu.Lock()
remote, exists := p.remoteHosts[host]
if exists {
delete(p.remoteHosts, host)
}
for id, owner := range p.remoteOwners {
if owner.Host == host {
delete(p.remoteOwners, id)
}
}
p.remoteMu.Unlock()
if exists {
remote.Conn.Close()
}
connectedRemotes.Set(float64(p.RemoteCount()))
}
func (p *CartPool) RemoteCount() int {
p.remoteMu.RLock()
defer p.remoteMu.RUnlock()
return len(p.remoteHosts)
}
// RemoteHostNames returns a snapshot of connected remote host identifiers.
func (p *CartPool) RemoteHostNames() []string {
p.remoteMu.RLock()
defer p.remoteMu.RUnlock()
hosts := make([]string, 0, len(p.remoteHosts))
for host := range p.remoteHosts {
hosts = append(hosts, host)
}
return hosts
}
func (p *CartPool) IsKnown(host string) bool {
if host == p.hostname {
return true
}
p.remoteMu.RLock()
defer p.remoteMu.RUnlock()
_, ok := p.remoteHosts[host]
return ok
}
func (p *CartPool) pingLoop(remote *RemoteHostGRPC) {
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for range ticker.C {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
_, err := remote.ControlClient.Ping(ctx, &messages.Empty{})
cancel()
if err != nil {
remote.MissedPings++
log.Printf("Ping %s failed (%d)", remote.Host, remote.MissedPings)
if !remote.IsHealthy() {
log.Printf("Remote %s unhealthy, removing", remote.Host)
p.RemoveHost(remote.Host)
return
}
continue
}
remote.MissedPings = 0
}
}
func (p *CartPool) IsHealthy() bool {
p.remoteMu.RLock()
defer p.remoteMu.RUnlock()
for _, r := range p.remoteHosts {
if !r.IsHealthy() {
return false
}
}
return true
}
func (p *CartPool) Negotiate() {
negotiationCount.Inc()
p.remoteMu.RLock()
hosts := make([]string, 0, len(p.remoteHosts)+1)
hosts = append(hosts, p.hostname)
remotes := make([]*RemoteHostGRPC, 0, len(p.remoteHosts))
for h, r := range p.remoteHosts {
hosts = append(hosts, h)
remotes = append(remotes, r)
}
p.remoteMu.RUnlock()
for _, r := range remotes {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
reply, err := r.ControlClient.Negotiate(ctx, &messages.NegotiateRequest{KnownHosts: hosts})
cancel()
if err != nil {
log.Printf("Negotiate with %s failed: %v", r.Host, err)
continue
}
for _, h := range reply.Hosts {
if !p.IsKnown(h) {
p.AddRemote(h)
}
}
}
}
func (p *CartPool) broadcastOwnership(ids []CartId) {
if len(ids) == 0 {
return
}
uids := make([]uint64, len(ids))
for i, id := range ids {
uids[i] = uint64(id)
}
p.remoteMu.RLock()
remotes := make([]*RemoteHostGRPC, 0, len(p.remoteHosts))
for _, r := range p.remoteHosts {
if r.IsHealthy() {
remotes = append(remotes, r)
}
}
p.remoteMu.RUnlock()
for _, remote := range remotes {
go func(rh *RemoteHostGRPC) {
_, err := rh.ControlClient.AnnounceOwnership(context.Background(), &messages.OwnershipAnnounce{
Host: p.hostname,
CartIds: uids,
})
if err != nil {
log.Printf("ownership announce to %s failed: %v", rh.Host, err)
}
}(remote)
}
}
func (p *CartPool) broadcastExpiry(ids []CartId) {
if len(ids) == 0 {
return
}
uids := make([]uint64, len(ids))
for i, id := range ids {
uids[i] = uint64(id)
}
p.remoteMu.RLock()
remotes := make([]*RemoteHostGRPC, 0, len(p.remoteHosts))
for _, r := range p.remoteHosts {
if r.IsHealthy() {
remotes = append(remotes, r)
}
}
p.remoteMu.RUnlock()
for _, remote := range remotes {
go func(rh *RemoteHostGRPC) {
_, err := rh.ControlClient.AnnounceExpiry(context.Background(), &messages.ExpiryAnnounce{
Host: p.hostname,
CartIds: uids,
})
if err != nil {
log.Printf("expiry announce to %s failed: %v", rh.Host, err)
}
}(remote)
}
}
func (p *CartPool) AdoptRemoteOwnership(host string, ids []string) {
if host == "" || host == p.hostname {
return
}
remoteHost, ok := p.remoteHosts[host]
if !ok {
log.Printf("AdoptRemoteOwnership: unknown host %s", host)
}
p.remoteMu.Lock()
defer p.remoteMu.Unlock()
for _, s := range ids {
if s == "" {
continue
}
parsed, ok := ParseCartId(s)
if !ok {
continue
}
if existing, ok := p.remoteOwners[parsed]; ok && existing != remoteHost {
continue
}
p.localMu.RLock()
_, localHas := p.grains[uint64(parsed)]
p.localMu.RUnlock()
if localHas {
continue
}
p.remoteOwners[parsed] = remoteHost
}
}
func (p *CartPool) HandleRemoteExpiry(host string, ids []uint64) {
if host == "" || host == p.hostname {
return
}
p.remoteMu.Lock()
defer p.remoteMu.Unlock()
for _, raw := range ids {
id := CartId(raw)
if owner, ok := p.remoteOwners[id]; ok && owner.Host == host {
delete(p.remoteOwners, id)
}
}
}
func (p *CartPool) getOrClaimGrain(id CartId) (*CartGrain, error) {
p.localMu.RLock()
grain, exists := p.grains[uint64(id)]
p.localMu.RUnlock()
if exists && grain != nil {
return grain, nil
}
p.remoteMu.RLock()
remoteHost, found := p.remoteOwners[id]
p.remoteMu.RUnlock()
if found && remoteHost != nil && remoteHost.Host != "" {
return nil, ErrNotOwner
}
grain, err := p.getLocalGrain(id)
if err != nil {
return nil, err
}
go p.broadcastOwnership([]CartId{id})
return grain, nil
}
// ErrNotOwner is returned when a cart belongs to another host.
var ErrNotOwner = fmt.Errorf("not owner")
// Apply applies a mutation to a grain.
func (p *CartPool) Apply(id CartId, mutation interface{}) (*CartGrain, error) {
grain, err := p.getOrClaimGrain(id)
if err != nil {
return nil, err
}
start := time.Now()
result, applyErr := grain.Apply(mutation, false)
mutationType := "unknown"
if mutation != nil {
if t := reflect.TypeOf(mutation); t != nil {
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Name() != "" {
mutationType = t.Name()
}
}
}
cartMutationLatencySeconds.WithLabelValues(mutationType).Observe(time.Since(start).Seconds())
if applyErr == nil && result != nil {
cartMutationsTotal.Inc()
p.RefreshExpiry(id)
cartActiveGrains.Set(float64(p.DebugGrainCount()))
} else if applyErr != nil {
cartMutationFailuresTotal.Inc()
}
return result, applyErr
}
// Get returns the current state of a grain.
func (p *CartPool) Get(id CartId) (*CartGrain, error) {
grain, err := p.getOrClaimGrain(id)
if err != nil {
return nil, err
}
return grain.GetCurrentState()
}
// OwnerHost reports the remote owner (if any) for the supplied cart id.
func (p *CartPool) OwnerHost(id CartId) (Host, bool) {
p.remoteMu.RLock()
defer p.remoteMu.RUnlock()
owner, ok := p.remoteOwners[id]
return owner, ok
}
// Hostname returns the local hostname (pod IP).
func (p *CartPool) Hostname() string {
return p.hostname
}
// Close notifies remotes that this host is shutting down.
func (p *CartPool) Close() {
p.remoteMu.Lock()
defer p.remoteMu.Unlock()
for _, r := range p.remoteHosts {
go func(rh *RemoteHostGRPC) {
_, err := rh.ControlClient.Closing(context.Background(), &messages.ClosingNotice{Host: p.hostname})
if err != nil {
log.Printf("Close notify to %s failed: %v", rh.Host, err)
}
}(r)
}
if p.purgeTicker != nil {
p.purgeTicker.Stop()
}
}

View File

@@ -1,121 +0,0 @@
package main
import (
"context"
"fmt"
"log"
"net"
"time"
messages "git.tornberg.me/go-cart-actor/proto"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
)
// cartActorGRPCServer implements the ControlPlane gRPC services.
// It delegates cart operations to a grain pool and cluster operations to a synced pool.
type cartActorGRPCServer struct {
messages.UnimplementedControlPlaneServer
pool *CartPool
}
// NewCartActorGRPCServer creates and initializes the server.
func NewCartActorGRPCServer(pool *CartPool) *cartActorGRPCServer {
return &cartActorGRPCServer{
pool: pool,
}
}
func (s *cartActorGRPCServer) AnnounceOwnership(ctx context.Context, req *messages.OwnershipAnnounce) (*messages.OwnerChangeAck, error) {
for _, cartId := range req.CartIds {
s.pool.removeLocalGrain(CartId(cartId))
}
log.Printf("Ack count: %d", len(req.CartIds))
return &messages.OwnerChangeAck{
Accepted: true,
Message: "ownership announced",
}, nil
}
func (s *cartActorGRPCServer) AnnounceExpiry(ctx context.Context, req *messages.ExpiryAnnounce) (*messages.OwnerChangeAck, error) {
s.pool.HandleRemoteExpiry(req.GetHost(), req.GetCartIds())
return &messages.OwnerChangeAck{
Accepted: true,
Message: "expiry acknowledged",
}, nil
}
// ControlPlane: Ping
func (s *cartActorGRPCServer) Ping(ctx context.Context, _ *messages.Empty) (*messages.PingReply, error) {
// Expose cart owner cookie (first-touch owner = this host) for HTTP gateways translating gRPC metadata.
// Gateways that propagate Set-Cookie can help establish sticky sessions at the edge.
//_ = grpc.SendHeader(ctx, metadata.Pairs("set-cookie", fmt.Sprintf("cartowner=%s; Path=/; HttpOnly", s.syncedPool.Hostname())))
return &messages.PingReply{
Host: s.pool.Hostname(),
UnixTime: time.Now().Unix(),
}, nil
}
// ControlPlane: Negotiate (merge host views)
func (s *cartActorGRPCServer) Negotiate(ctx context.Context, req *messages.NegotiateRequest) (*messages.NegotiateReply, error) {
hostSet := make(map[string]struct{})
// Caller view
for _, h := range req.GetKnownHosts() {
if h != "" {
hostSet[h] = struct{}{}
}
}
// This host
hostSet[s.pool.Hostname()] = struct{}{}
// Known remotes
for _, h := range s.pool.RemoteHostNames() {
hostSet[h] = struct{}{}
}
out := make([]string, 0, len(hostSet))
for h := range hostSet {
out = append(out, h)
}
return &messages.NegotiateReply{Hosts: out}, nil
}
// ControlPlane: GetCartIds (locally owned carts only)
func (s *cartActorGRPCServer) GetCartIds(ctx context.Context, _ *messages.Empty) (*messages.CartIdsReply, error) {
return &messages.CartIdsReply{CartIds: s.pool.LocalCartIDs()}, nil
}
// ControlPlane: Closing (peer shutdown notification)
func (s *cartActorGRPCServer) Closing(ctx context.Context, req *messages.ClosingNotice) (*messages.OwnerChangeAck, error) {
if req.GetHost() != "" {
s.pool.RemoveHost(req.GetHost())
}
return &messages.OwnerChangeAck{
Accepted: true,
Message: "removed host",
}, nil
}
// StartGRPCServer configures and starts the unified gRPC server on the given address.
// It registers both the CartActor and ControlPlane services.
func StartGRPCServer(addr string, pool *CartPool) (*grpc.Server, error) {
lis, err := net.Listen("tcp", addr)
if err != nil {
return nil, fmt.Errorf("failed to listen: %w", err)
}
grpcServer := grpc.NewServer()
server := NewCartActorGRPCServer(pool)
messages.RegisterControlPlaneServer(grpcServer, server)
reflection.Register(grpcServer)
log.Printf("gRPC server listening on %s", addr)
go func() {
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("failed to serve gRPC: %v", err)
}
}()
return grpcServer, nil
}

View File

@@ -1,82 +0,0 @@
package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/proto"
)
// 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

@@ -1,61 +0,0 @@
package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/proto"
)
// 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

@@ -1,301 +0,0 @@
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

@@ -1,57 +0,0 @@
package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/proto"
)
// 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(),
)
}

137
pkg/actor/disk_storage.go Normal file
View File

@@ -0,0 +1,137 @@
package actor
import (
"errors"
"fmt"
"log"
"os"
"path/filepath"
"sync"
"time"
"github.com/gogo/protobuf/proto"
)
type QueueEvent struct {
TimeStamp time.Time
Message proto.Message
}
type DiskStorage[V any] struct {
*StateStorage
path string
done chan struct{}
queue *sync.Map // map[uint64][]QueueEvent
}
type LogStorage[V any] interface {
LoadEvents(id uint64, grain Grain[V]) error
AppendEvent(id uint64, msg ...proto.Message) error
}
func NewDiskStorage[V any](path string, registry MutationRegistry) *DiskStorage[V] {
return &DiskStorage[V]{
StateStorage: NewState(registry),
path: path,
done: make(chan struct{}),
}
}
func (s *DiskStorage[V]) SaveLoop(duration time.Duration) {
s.queue = &sync.Map{}
ticker := time.NewTicker(duration)
defer ticker.Stop()
for {
select {
case <-s.done:
s.save()
return
case <-ticker.C:
s.save()
}
}
}
func (s *DiskStorage[V]) save() {
carts := 0
lines := 0
s.queue.Range(func(key, value any) bool {
id := key.(uint64)
path := s.logPath(id)
fh, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Printf("failed to open event log file: %v", err)
return true
}
defer fh.Close()
if qe, ok := value.([]QueueEvent); ok {
for _, msg := range qe {
if err := s.Append(fh, msg.Message, msg.TimeStamp); err != nil {
log.Printf("failed to append event to log file: %v", err)
}
lines++
}
}
carts++
s.queue.Delete(id)
return true
})
if lines > 0 {
log.Printf("Appended %d carts and %d lines to disk", carts, lines)
}
}
func (s *DiskStorage[V]) logPath(id uint64) string {
return filepath.Join(s.path, fmt.Sprintf("%d.events.log", id))
}
func (s *DiskStorage[V]) LoadEvents(id uint64, grain Grain[V]) error {
path := s.logPath(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 {
return fmt.Errorf("open replay file: %w", err)
}
defer fh.Close()
return s.Load(fh, func(msg proto.Message) {
s.registry.Apply(grain, msg)
})
}
func (s *DiskStorage[V]) Close() {
s.save()
close(s.done)
}
func (s *DiskStorage[V]) AppendEvent(id uint64, msg ...proto.Message) error {
if s.queue != nil {
queue := make([]QueueEvent, 0)
data, found := s.queue.Load(id)
if found {
queue = data.([]QueueEvent)
}
for _, m := range msg {
queue = append(queue, QueueEvent{Message: m, TimeStamp: time.Now()})
}
s.queue.Store(id, queue)
return nil
} else {
path := s.logPath(id)
fh, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Printf("failed to open event log file: %v", err)
return err
}
defer fh.Close()
for _, m := range msg {
err = s.Append(fh, m, time.Now())
}
return err
}
}

13
pkg/actor/grain.go Normal file
View File

@@ -0,0 +1,13 @@
package actor
import (
"time"
)
type Grain[V any] interface {
GetId() uint64
GetLastAccess() time.Time
GetLastChange() time.Time
GetCurrentState() (*V, error)
}

41
pkg/actor/grain_pool.go Normal file
View File

@@ -0,0 +1,41 @@
package actor
import (
"net/http"
"github.com/gogo/protobuf/proto"
)
type MutationResult[V any] struct {
Result V `json:"result"`
Mutations []ApplyResult `json:"mutations,omitempty"`
}
type GrainPool[V any] interface {
Apply(id uint64, mutation ...proto.Message) (*MutationResult[V], error)
Get(id uint64) (V, error)
OwnerHost(id uint64) (Host, bool)
Hostname() string
TakeOwnership(id uint64)
HandleOwnershipChange(host string, ids []uint64) error
HandleRemoteExpiry(host string, ids []uint64) error
Negotiate(otherHosts []string)
GetLocalIds() []uint64
RemoveHost(host string)
IsHealthy() bool
IsKnown(string) bool
Close()
}
// Host abstracts a remote node capable of proxying cart requests.
type Host interface {
AnnounceExpiry(ids []uint64)
Negotiate(otherHosts []string) ([]string, error)
Name() string
Proxy(id uint64, w http.ResponseWriter, r *http.Request) (bool, error)
GetActorIds() []uint64
Close() error
Ping() bool
IsHealthy() bool
AnnounceOwnership(ownerHost string, ids []uint64)
}

119
pkg/actor/grpc_server.go Normal file
View File

@@ -0,0 +1,119 @@
package actor
import (
"context"
"fmt"
"log"
"net"
"time"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
)
// ControlServer implements the ControlPlane gRPC services.
// It delegates to a grain pool and cluster operations to a synced pool.
type ControlServer[V any] struct {
messages.UnimplementedControlPlaneServer
pool GrainPool[V]
}
func (s *ControlServer[V]) AnnounceOwnership(ctx context.Context, req *messages.OwnershipAnnounce) (*messages.OwnerChangeAck, error) {
err := s.pool.HandleOwnershipChange(req.Host, req.Ids)
if err != nil {
return &messages.OwnerChangeAck{
Accepted: false,
Message: "owner change failed",
}, err
}
log.Printf("Ack count: %d", len(req.Ids))
return &messages.OwnerChangeAck{
Accepted: true,
Message: "ownership announced",
}, nil
}
func (s *ControlServer[V]) AnnounceExpiry(ctx context.Context, req *messages.ExpiryAnnounce) (*messages.OwnerChangeAck, error) {
err := s.pool.HandleRemoteExpiry(req.Host, req.Ids)
return &messages.OwnerChangeAck{
Accepted: err == nil,
Message: "expiry acknowledged",
}, nil
}
// ControlPlane: Ping
func (s *ControlServer[V]) Ping(ctx context.Context, _ *messages.Empty) (*messages.PingReply, error) {
// log.Printf("got ping")
return &messages.PingReply{
Host: s.pool.Hostname(),
UnixTime: time.Now().Unix(),
}, nil
}
// ControlPlane: Negotiate (merge host views)
func (s *ControlServer[V]) Negotiate(ctx context.Context, req *messages.NegotiateRequest) (*messages.NegotiateReply, error) {
s.pool.Negotiate(req.KnownHosts)
return &messages.NegotiateReply{Hosts: req.GetKnownHosts()}, nil
}
// ControlPlane: GetCartIds (locally owned carts only)
func (s *ControlServer[V]) GetLocalActorIds(ctx context.Context, _ *messages.Empty) (*messages.ActorIdsReply, error) {
return &messages.ActorIdsReply{Ids: s.pool.GetLocalIds()}, nil
}
// ControlPlane: Closing (peer shutdown notification)
func (s *ControlServer[V]) Closing(ctx context.Context, req *messages.ClosingNotice) (*messages.OwnerChangeAck, error) {
if req.GetHost() != "" {
s.pool.RemoveHost(req.GetHost())
}
return &messages.OwnerChangeAck{
Accepted: true,
Message: "removed host",
}, nil
}
type ServerConfig struct {
Addr string
Options []grpc.ServerOption
}
func NewServerConfig(addr string, options ...grpc.ServerOption) ServerConfig {
return ServerConfig{
Addr: addr,
Options: options,
}
}
func DefaultServerConfig() ServerConfig {
return NewServerConfig(":1337")
}
// StartGRPCServer configures and starts the unified gRPC server on the given address.
// It registers both the CartActor and ControlPlane services.
func NewControlServer[V any](config ServerConfig, pool GrainPool[V]) (*grpc.Server, error) {
lis, err := net.Listen("tcp", config.Addr)
if err != nil {
return nil, fmt.Errorf("failed to listen: %w", err)
}
grpcServer := grpc.NewServer(config.Options...)
server := &ControlServer[V]{
pool: pool,
}
messages.RegisterControlPlaneServer(grpcServer, server)
reflection.Register(grpcServer)
log.Printf("gRPC server listening as %s on %s", pool.Hostname(), config.Addr)
go func() {
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("failed to serve gRPC: %v", err)
}
}()
return grpcServer, nil
}

View File

@@ -0,0 +1,226 @@
package actor
import (
"fmt"
"log"
"reflect"
"sync"
"github.com/gogo/protobuf/proto"
)
type ApplyResult struct {
Type string `json:"type"`
Mutation proto.Message `json:"mutation"`
Error error `json:"error,omitempty"`
}
type MutationRegistry interface {
Apply(grain any, msg ...proto.Message) ([]ApplyResult, error)
RegisterMutations(handlers ...MutationHandler)
Create(typeName string) (proto.Message, bool)
GetTypeName(msg proto.Message) (string, bool)
//GetStorageEvent(msg proto.Message) StorageEvent
//FromStorageEvent(event StorageEvent) (proto.Message, error)
}
type ProtoMutationRegistry struct {
mutationRegistryMu sync.RWMutex
mutationRegistry map[reflect.Type]MutationHandler
}
var (
ErrMutationNotRegistered = &MutationError{
Message: "mutation not registered",
Code: 255,
StatusCode: 500,
}
)
type MutationError struct {
Message string `json:"message"`
Code uint32 `json:"code"`
StatusCode uint32 `json:"status_code"`
}
func (m MutationError) Error() string {
return m.Message
}
// 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
}
}
type MutationHandler interface {
Handle(state any, msg proto.Message) error
Name() string
Type() reflect.Type
Create() proto.Message
}
// RegisteredMutation stores metadata + the execution closure.
type RegisteredMutation[V any, T proto.Message] struct {
name string
handler func(*V, T) error
create func() T
msgType reflect.Type
}
func NewMutation[V any, T proto.Message](handler func(*V, T) error, create func() T) *RegisteredMutation[V, T] {
// Derive the name and message type from a concrete instance produced by create().
// This avoids relying on reflect.TypeFor (which can yield unexpected results in some toolchains)
// and ensures we always peel off the pointer layer for proto messages.
instance := create()
rt := reflect.TypeOf(instance)
if rt.Kind() == reflect.Ptr {
rt = rt.Elem()
}
return &RegisteredMutation[V, T]{
name: rt.Name(),
handler: handler,
create: create,
msgType: rt,
}
}
func (m *RegisteredMutation[V, T]) Handle(state any, msg proto.Message) error {
return m.handler(state.(*V), msg.(T))
}
func (m *RegisteredMutation[V, T]) Name() string {
return m.name
}
func (m *RegisteredMutation[V, T]) Create() proto.Message {
return m.create()
}
func (m *RegisteredMutation[V, T]) Type() reflect.Type {
return m.msgType
}
func NewMutationRegistry() MutationRegistry {
return &ProtoMutationRegistry{
mutationRegistry: make(map[reflect.Type]MutationHandler),
mutationRegistryMu: sync.RWMutex{},
}
}
func (r *ProtoMutationRegistry) RegisterMutations(handlers ...MutationHandler) {
r.mutationRegistryMu.Lock()
defer r.mutationRegistryMu.Unlock()
for _, handler := range handlers {
r.mutationRegistry[handler.Type()] = handler
}
}
func (r *ProtoMutationRegistry) GetTypeName(msg proto.Message) (string, bool) {
r.mutationRegistryMu.RLock()
defer r.mutationRegistryMu.RUnlock()
rt := indirectType(reflect.TypeOf(msg))
if handler, ok := r.mutationRegistry[rt]; ok {
return handler.Name(), true
}
return "", false
}
func (r *ProtoMutationRegistry) getHandler(typeName string) MutationHandler {
r.mutationRegistryMu.Lock()
defer r.mutationRegistryMu.Unlock()
for _, handler := range r.mutationRegistry {
if handler.Name() == typeName {
return handler
}
}
return nil
}
func (r *ProtoMutationRegistry) Create(typeName string) (proto.Message, bool) {
handler := r.getHandler(typeName)
if handler == nil {
log.Printf("missing handler for %s", typeName)
return nil, false
}
return handler.Create(), true
}
// ApplyRegistered attempts to apply a registered mutation.
// Returns updated grain if successful.
//
// If the mutation is not registered, returns (nil, ErrMutationNotRegistered).
func (r *ProtoMutationRegistry) Apply(grain any, msg ...proto.Message) ([]ApplyResult, error) {
results := make([]ApplyResult, 0, len(msg))
if grain == nil {
return results, fmt.Errorf("nil grain")
}
if msg == nil {
return results, fmt.Errorf("nil mutation message")
}
for _, m := range msg {
rt := indirectType(reflect.TypeOf(m))
r.mutationRegistryMu.RLock()
entry, ok := r.mutationRegistry[rt]
r.mutationRegistryMu.RUnlock()
if !ok {
results = append(results, ApplyResult{Error: ErrMutationNotRegistered, Type: rt.Name(), Mutation: m})
continue
}
err := entry.Handle(grain, m)
results = append(results, ApplyResult{Error: err, Type: rt.Name(), Mutation: m})
}
// if entry.updateTotals {
// grain.UpdateTotals()
// }
return results, nil
}
// RegisteredMutations returns metadata for all registered mutations (snapshot).
func (r *ProtoMutationRegistry) RegisteredMutations() []string {
r.mutationRegistryMu.RLock()
defer r.mutationRegistryMu.RUnlock()
out := make([]string, 0, len(r.mutationRegistry))
for _, entry := range r.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 (r *ProtoMutationRegistry) RegisteredMutationTypes() []reflect.Type {
r.mutationRegistryMu.RLock()
defer r.mutationRegistryMu.RUnlock()
out := make([]reflect.Type, 0, len(r.mutationRegistry))
for _, entry := range r.mutationRegistry {
out = append(out, entry.Type())
}
return out
}
func indirectType(t reflect.Type) reflect.Type {
for t.Kind() == reflect.Ptr {
t = t.Elem()
}
return t
}

View File

@@ -0,0 +1,134 @@
package actor
import (
"errors"
"reflect"
"slices"
"testing"
"git.tornberg.me/go-cart-actor/pkg/messages"
)
type cartState struct {
calls int
lastAdded *messages.AddItem
}
func TestRegisteredMutationBasics(t *testing.T) {
reg := NewMutationRegistry().(*ProtoMutationRegistry)
addItemMutation := NewMutation(
func(state *cartState, msg *messages.AddItem) error {
state.calls++
// copy to avoid external mutation side-effects (not strictly necessary for the test)
cp := *msg
state.lastAdded = &cp
return nil
},
func() *messages.AddItem { return &messages.AddItem{} },
)
// Sanity check on mutation metadata
if addItemMutation.Name() != "AddItem" {
t.Fatalf("expected mutation Name() == AddItem, got %s", addItemMutation.Name())
}
if got, want := addItemMutation.Type(), reflect.TypeOf(messages.AddItem{}); got != want {
t.Fatalf("expected Type() == %v, got %v", want, got)
}
reg.RegisterMutations(addItemMutation)
// RegisteredMutations: membership (order not guaranteed)
names := reg.RegisteredMutations()
if !slices.Contains(names, "AddItem") {
t.Fatalf("RegisteredMutations missing AddItem, got %v", names)
}
// RegisteredMutationTypes: membership (order not guaranteed)
types := reg.RegisteredMutationTypes()
if !slices.Contains(types, reflect.TypeOf(messages.AddItem{})) {
t.Fatalf("RegisteredMutationTypes missing AddItem type, got %v", types)
}
// GetTypeName should resolve for a pointer instance
name, ok := reg.GetTypeName(&messages.AddItem{})
if !ok || name != "AddItem" {
t.Fatalf("GetTypeName returned (%q,%v), expected (AddItem,true)", name, ok)
}
// GetTypeName should fail for unregistered type
if name, ok := reg.GetTypeName(&messages.Noop{}); ok || name != "" {
t.Fatalf("expected GetTypeName to fail for unregistered message, got (%q,%v)", name, ok)
}
// Create by name
msg, ok := reg.Create("AddItem")
if !ok {
t.Fatalf("Create failed for registered mutation")
}
if _, isAddItem := msg.(*messages.AddItem); !isAddItem {
t.Fatalf("Create returned wrong concrete type: %T", msg)
}
// Create unknown
if m2, ok := reg.Create("Unknown"); ok || m2 != nil {
t.Fatalf("Create should fail for unknown mutation, got (%T,%v)", m2, ok)
}
// Apply happy path
state := &cartState{}
add := &messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
if _, err := reg.Apply(state, add); err != nil {
t.Fatalf("Apply returned error: %v", err)
}
if state.calls != 1 {
t.Fatalf("handler not invoked expected calls=1 got=%d", state.calls)
}
if state.lastAdded == nil || state.lastAdded.ItemId != 42 || state.lastAdded.Quantity != 3 {
t.Fatalf("state not updated correctly: %+v", state.lastAdded)
}
// Apply nil grain
if _, err := reg.Apply(nil, add); err == nil {
t.Fatalf("expected error for nil grain")
}
// Apply nil message
if _, err := reg.Apply(state, nil); err == nil {
t.Fatalf("expected error for nil mutation message")
}
// Apply unregistered message
if _, err := reg.Apply(state, &messages.Noop{}); !errors.Is(err, ErrMutationNotRegistered) {
t.Fatalf("expected ErrMutationNotRegistered, got %v", err)
}
}
// func TestConcurrentSafeRegistrationLookup(t *testing.T) {
// // This test is light-weight; it ensures locks don't deadlock under simple concurrent access.
// reg := NewMutationRegistry().(*ProtoMutationRegistry)
// mut := NewMutation[cartState, *messages.Noop](
// func(state *cartState, msg *messages.Noop) error { state.calls++; return nil },
// func() *messages.Noop { return &messages.Noop{} },
// )
// reg.RegisterMutations(mut)
// done := make(chan struct{})
// const workers = 25
// for i := 0; i < workers; i++ {
// go func() {
// for j := 0; j < 100; j++ {
// _, _ = reg.Create("Noop")
// _, _ = reg.GetTypeName(&messages.Noop{})
// _ = reg.Apply(&cartState{}, &messages.Noop{})
// }
// done <- struct{}{}
// }()
// }
// for i := 0; i < workers; i++ {
// <-done
// }
// }
// Helpers

View File

@@ -0,0 +1,433 @@
package actor
import (
"fmt"
"log"
"maps"
"sync"
"time"
"github.com/gogo/protobuf/proto"
)
type SimpleGrainPool[V any] struct {
// fields and methods
localMu sync.RWMutex
grains map[uint64]Grain[V]
mutationRegistry MutationRegistry
spawn func(id uint64) (Grain[V], error)
spawnHost func(host string) (Host, error)
storage LogStorage[V]
ttl time.Duration
poolSize int
// Cluster coordination --------------------------------------------------
hostname string
remoteMu sync.RWMutex
remoteOwners map[uint64]Host
remoteHosts map[string]Host
//discardedHostHandler *DiscardedHostHandler
// House-keeping ---------------------------------------------------------
purgeTicker *time.Ticker
}
type GrainPoolConfig[V any] struct {
Hostname string
Spawn func(id uint64) (Grain[V], error)
SpawnHost func(host string) (Host, error)
TTL time.Duration
PoolSize int
MutationRegistry MutationRegistry
Storage LogStorage[V]
}
func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V], error) {
p := &SimpleGrainPool[V]{
grains: make(map[uint64]Grain[V]),
mutationRegistry: config.MutationRegistry,
storage: config.Storage,
spawn: config.Spawn,
spawnHost: config.SpawnHost,
ttl: config.TTL,
poolSize: config.PoolSize,
hostname: config.Hostname,
remoteOwners: make(map[uint64]Host),
remoteHosts: make(map[string]Host),
}
p.purgeTicker = time.NewTicker(time.Minute)
go func() {
for range p.purgeTicker.C {
p.purge()
}
}()
return p, nil
}
func (p *SimpleGrainPool[V]) purge() {
purgeLimit := time.Now().Add(-p.ttl)
purgedIds := make([]uint64, 0, len(p.grains))
p.localMu.Lock()
for id, grain := range p.grains {
if grain.GetLastAccess().Before(purgeLimit) {
purgedIds = append(purgedIds, id)
delete(p.grains, id)
}
}
p.localMu.Unlock()
p.forAllHosts(func(remote Host) {
remote.AnnounceExpiry(purgedIds)
})
}
// LocalUsage returns the number of resident grains and configured capacity.
func (p *SimpleGrainPool[V]) LocalUsage() (int, int) {
p.localMu.RLock()
defer p.localMu.RUnlock()
return len(p.grains), p.poolSize
}
// LocalCartIDs returns the currently owned cart ids (for control-plane RPCs).
func (p *SimpleGrainPool[V]) GetLocalIds() []uint64 {
p.localMu.RLock()
defer p.localMu.RUnlock()
ids := make([]uint64, 0, len(p.grains))
for _, g := range p.grains {
if g == nil {
continue
}
ids = append(ids, uint64(g.GetId()))
}
return ids
}
func (p *SimpleGrainPool[V]) HandleRemoteExpiry(host string, ids []uint64) error {
p.remoteMu.Lock()
defer p.remoteMu.Unlock()
for _, id := range ids {
delete(p.remoteOwners, id)
}
return nil
}
func (p *SimpleGrainPool[V]) HandleOwnershipChange(host string, ids []uint64) error {
log.Printf("host %s now owns %d cart ids", host, len(ids))
p.remoteMu.RLock()
remoteHost, exists := p.remoteHosts[host]
p.remoteMu.RUnlock()
if !exists {
createdHost, err := p.AddRemote(host)
if err != nil {
return err
}
remoteHost = createdHost
}
p.remoteMu.Lock()
defer p.remoteMu.Unlock()
p.localMu.Lock()
defer p.localMu.Unlock()
for _, id := range ids {
log.Printf("Handling ownership change for cart %d to host %s", id, host)
delete(p.grains, id)
p.remoteOwners[id] = remoteHost
}
return nil
}
// TakeOwnership takes ownership of a grain.
func (p *SimpleGrainPool[V]) TakeOwnership(id uint64) {
p.broadcastOwnership([]uint64{id})
}
func (p *SimpleGrainPool[V]) AddRemote(host string) (Host, error) {
if host == "" {
return nil, fmt.Errorf("host is empty")
}
if host == p.hostname {
return nil, fmt.Errorf("same host, this should not happen")
}
p.remoteMu.RLock()
existing, found := p.remoteHosts[host]
p.remoteMu.RUnlock()
if found {
return existing, nil
}
remote, err := p.spawnHost(host)
if err != nil {
log.Printf("AddRemote %s failed: %v", host, err)
return nil, err
}
p.remoteMu.Lock()
p.remoteHosts[host] = remote
p.remoteMu.Unlock()
// connectedRemotes.Set(float64(p.RemoteCount()))
log.Printf("Connected to remote host %s", host)
go p.pingLoop(remote)
go p.initializeRemote(remote)
go p.SendNegotiation()
return remote, nil
}
func (p *SimpleGrainPool[V]) initializeRemote(remote Host) {
remotesIds := remote.GetActorIds()
p.remoteMu.Lock()
for _, id := range remotesIds {
p.localMu.Lock()
delete(p.grains, id)
p.localMu.Unlock()
if _, exists := p.remoteOwners[id]; !exists {
p.remoteOwners[id] = remote
}
}
p.remoteMu.Unlock()
}
func (p *SimpleGrainPool[V]) RemoveHost(host string) {
p.remoteMu.Lock()
remote, exists := p.remoteHosts[host]
if exists {
go remote.Close()
delete(p.remoteHosts, host)
}
count := 0
for id, owner := range p.remoteOwners {
if owner.Name() == host {
count++
delete(p.remoteOwners, id)
}
}
log.Printf("Removing host %s, grains: %d", host, count)
p.remoteMu.Unlock()
if exists {
remote.Close()
}
// connectedRemotes.Set(float64(p.RemoteCount()))
}
func (p *SimpleGrainPool[V]) RemoteCount() int {
p.remoteMu.RLock()
defer p.remoteMu.RUnlock()
return len(p.remoteHosts)
}
// RemoteHostNames returns a snapshot of connected remote host identifiers.
func (p *SimpleGrainPool[V]) RemoteHostNames() []string {
p.remoteMu.RLock()
defer p.remoteMu.RUnlock()
hosts := make([]string, 0, len(p.remoteHosts))
for host := range p.remoteHosts {
hosts = append(hosts, host)
}
return hosts
}
func (p *SimpleGrainPool[V]) IsKnown(host string) bool {
if host == p.hostname {
return true
}
p.remoteMu.RLock()
defer p.remoteMu.RUnlock()
_, ok := p.remoteHosts[host]
return ok
}
func (p *SimpleGrainPool[V]) pingLoop(remote Host) {
remote.Ping()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
if !remote.Ping() {
if !remote.IsHealthy() {
log.Printf("Remote %s unhealthy, removing", remote.Name())
p.Close()
p.RemoveHost(remote.Name())
return
}
continue
}
}
}
func (p *SimpleGrainPool[V]) IsHealthy() bool {
p.remoteMu.RLock()
defer p.remoteMu.RUnlock()
for _, r := range p.remoteHosts {
if !r.IsHealthy() {
return false
}
}
return true
}
func (p *SimpleGrainPool[V]) Negotiate(otherHosts []string) {
for _, host := range otherHosts {
if host != p.hostname {
p.remoteMu.RLock()
_, ok := p.remoteHosts[host]
p.remoteMu.RUnlock()
if !ok {
go p.AddRemote(host)
}
}
}
}
func (p *SimpleGrainPool[V]) SendNegotiation() {
//negotiationCount.Inc()
p.remoteMu.RLock()
hosts := make([]string, 0, len(p.remoteHosts)+1)
hosts = append(hosts, p.hostname)
remotes := make([]Host, 0, len(p.remoteHosts))
for h, r := range p.remoteHosts {
hosts = append(hosts, h)
remotes = append(remotes, r)
}
p.remoteMu.RUnlock()
p.forAllHosts(func(remote Host) {
knownByRemote, err := remote.Negotiate(hosts)
if err != nil {
log.Printf("Negotiate with %s failed: %v", remote.Name(), err)
return
}
for _, h := range knownByRemote {
if !p.IsKnown(h) {
go p.AddRemote(h)
}
}
})
}
func (p *SimpleGrainPool[V]) forAllHosts(fn func(Host)) {
p.remoteMu.RLock()
rh := maps.Clone(p.remoteHosts)
p.remoteMu.RUnlock()
wg := sync.WaitGroup{}
for _, host := range rh {
wg.Go(func() { fn(host) })
}
for name, host := range rh {
if !host.IsHealthy() {
host.Close()
p.remoteMu.Lock()
delete(p.remoteHosts, name)
p.remoteMu.Unlock()
}
}
}
func (p *SimpleGrainPool[V]) broadcastOwnership(ids []uint64) {
if len(ids) == 0 {
return
}
p.forAllHosts(func(rh Host) {
rh.AnnounceOwnership(p.hostname, ids)
})
log.Printf("%s taking ownership of %d ids", p.hostname, len(ids))
// go p.statsUpdate()
}
func (p *SimpleGrainPool[V]) getOrClaimGrain(id uint64) (Grain[V], error) {
p.localMu.RLock()
grain, exists := p.grains[id]
p.localMu.RUnlock()
if exists && grain != nil {
return grain, nil
}
grain, err := p.spawn(id)
if err != nil {
return nil, err
}
p.localMu.Lock()
p.grains[id] = grain
p.localMu.Unlock()
go p.broadcastOwnership([]uint64{id})
return grain, nil
}
// // ErrNotOwner is returned when a cart belongs to another host.
// var ErrNotOwner = fmt.Errorf("not owner")
// Apply applies a mutation to a grain.
func (p *SimpleGrainPool[V]) Apply(id uint64, mutation ...proto.Message) (*MutationResult[*V], error) {
grain, err := p.getOrClaimGrain(id)
if err != nil {
return nil, err
}
mutations, err := p.mutationRegistry.Apply(grain, mutation...)
if err != nil {
return nil, err
}
if p.storage != nil {
go func() {
if err := p.storage.AppendEvent(id, mutation...); err != nil {
log.Printf("failed to store mutation for grain %d: %v", id, err)
}
}()
}
result, err := grain.GetCurrentState()
if err != nil {
return nil, err
}
return &MutationResult[*V]{
Result: result,
Mutations: mutations,
}, nil
}
// Get returns the current state of a grain.
func (p *SimpleGrainPool[V]) Get(id uint64) (*V, error) {
grain, err := p.getOrClaimGrain(id)
if err != nil {
return nil, err
}
return grain.GetCurrentState()
}
// OwnerHost reports the remote owner (if any) for the supplied cart id.
func (p *SimpleGrainPool[V]) OwnerHost(id uint64) (Host, bool) {
p.remoteMu.RLock()
defer p.remoteMu.RUnlock()
owner, ok := p.remoteOwners[id]
return owner, ok
}
// Hostname returns the local hostname (pod IP).
func (p *SimpleGrainPool[V]) Hostname() string {
return p.hostname
}
// Close notifies remotes that this host is shutting down.
func (p *SimpleGrainPool[V]) Close() {
p.forAllHosts(func(rh Host) {
rh.Close()
})
if p.purgeTicker != nil {
p.purgeTicker.Stop()
}
}

98
pkg/actor/state.go Normal file
View File

@@ -0,0 +1,98 @@
package actor
import (
"bufio"
"encoding/json"
"errors"
"io"
"time"
"github.com/gogo/protobuf/proto"
)
type StateStorage struct {
registry MutationRegistry
}
type StorageEvent struct {
Type string `json:"type"`
TimeStamp time.Time `json:"timestamp"`
Mutation proto.Message `json:"mutation"`
}
type rawEvent struct {
Type string `json:"type"`
TimeStamp time.Time `json:"timestamp"`
Mutation json.RawMessage `json:"mutation"`
}
func NewState(registry MutationRegistry) *StateStorage {
return &StateStorage{
registry: registry,
}
}
var ErrUnknownType = errors.New("unknown type")
func (s *StateStorage) Load(r io.Reader, onMessage func(msg proto.Message)) error {
var err error
var evt *StorageEvent
scanner := bufio.NewScanner(r)
for err == nil {
evt, err = s.Read(scanner)
if err == nil {
onMessage(evt.Mutation)
}
}
if err == io.EOF {
return nil
}
return err
}
func (s *StateStorage) Append(io io.Writer, mutation proto.Message, timeStamp time.Time) error {
typeName, ok := s.registry.GetTypeName(mutation)
if !ok {
return ErrUnknownType
}
event := &StorageEvent{
Type: typeName,
TimeStamp: timeStamp,
Mutation: mutation,
}
jsonBytes, err := json.Marshal(event)
if err != nil {
return err
}
if _, err := io.Write(jsonBytes); err != nil {
return err
}
io.Write([]byte("\n"))
return nil
}
func (s *StateStorage) Read(r *bufio.Scanner) (*StorageEvent, error) {
var event rawEvent
if r.Scan() {
b := r.Bytes()
err := json.Unmarshal(b, &event)
if err != nil {
return nil, err
}
typeName := event.Type
mutation, ok := s.registry.Create(typeName)
if !ok {
return nil, ErrUnknownType
}
if err := json.Unmarshal(event.Mutation, mutation); err != nil {
return nil, err
}
return &StorageEvent{
Type: typeName,
TimeStamp: event.TimeStamp,
Mutation: mutation,
}, r.Err()
}
return nil, io.EOF
}

View File

@@ -0,0 +1,73 @@
package discovery
import (
"context"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/cache"
toolsWatch "k8s.io/client-go/tools/watch"
)
type K8sDiscovery struct {
ctx context.Context
client *kubernetes.Clientset
}
func (k *K8sDiscovery) Discover() ([]string, error) {
return k.DiscoverInNamespace("")
}
func (k *K8sDiscovery) DiscoverInNamespace(namespace string) ([]string, error) {
pods, err := k.client.CoreV1().Pods(namespace).List(k.ctx, metav1.ListOptions{
LabelSelector: "actor-pool=cart",
})
if err != nil {
return nil, err
}
hosts := make([]string, 0, len(pods.Items))
for _, pod := range pods.Items {
hosts = append(hosts, pod.Status.PodIP)
}
return hosts, nil
}
type HostChange struct {
Host string
Type watch.EventType
}
func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
timeout := int64(30)
watcherFn := func(options metav1.ListOptions) (watch.Interface, error) {
return k.client.CoreV1().Pods("").Watch(k.ctx, metav1.ListOptions{
LabelSelector: "actor-pool=cart",
TimeoutSeconds: &timeout,
})
}
watcher, err := toolsWatch.NewRetryWatcherWithContext(k.ctx, "1", &cache.ListWatch{WatchFunc: watcherFn})
if err != nil {
return nil, err
}
ch := make(chan HostChange)
go func() {
for event := range watcher.ResultChan() {
pod := event.Object.(*v1.Pod)
// log.Printf("pod change %+v", pod.Status.Phase == v1.PodRunning)
ch <- HostChange{
Host: pod.Status.PodIP,
Type: event.Type,
}
}
}()
return ch, nil
}
func NewK8sDiscovery(client *kubernetes.Clientset) *K8sDiscovery {
return &K8sDiscovery{
ctx: context.Background(),
client: client,
}
}

View File

@@ -1,82 +1,12 @@
package main
package discovery
import (
"context"
"sync"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/cache"
toolsWatch "k8s.io/client-go/tools/watch"
)
type Discovery interface {
Discover() ([]string, error)
Watch() (<-chan HostChange, error)
}
type K8sDiscovery struct {
ctx context.Context
client *kubernetes.Clientset
}
func (k *K8sDiscovery) Discover() ([]string, error) {
return k.DiscoverInNamespace("")
}
func (k *K8sDiscovery) DiscoverInNamespace(namespace string) ([]string, error) {
pods, err := k.client.CoreV1().Pods(namespace).List(k.ctx, metav1.ListOptions{
LabelSelector: "actor-pool=cart",
})
if err != nil {
return nil, err
}
hosts := make([]string, 0, len(pods.Items))
for _, pod := range pods.Items {
hosts = append(hosts, pod.Status.PodIP)
}
return hosts, nil
}
type HostChange struct {
Host string
Type watch.EventType
}
func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
timeout := int64(30)
watcherFn := func(options metav1.ListOptions) (watch.Interface, error) {
return k.client.CoreV1().Pods("").Watch(k.ctx, metav1.ListOptions{
LabelSelector: "actor-pool=cart",
TimeoutSeconds: &timeout,
})
}
watcher, err := toolsWatch.NewRetryWatcher("1", &cache.ListWatch{WatchFunc: watcherFn})
if err != nil {
return nil, err
}
ch := make(chan HostChange)
go func() {
for event := range watcher.ResultChan() {
pod := event.Object.(*v1.Pod)
ch <- HostChange{
Host: pod.Status.PodIP,
Type: event.Type,
}
}
}()
return ch, nil
}
func NewK8sDiscovery(client *kubernetes.Clientset) *K8sDiscovery {
return &K8sDiscovery{
ctx: context.Background(),
client: client,
}
}
// MockDiscovery is an in-memory Discovery implementation for tests.
// It allows deterministic injection of host additions/removals without
// depending on Kubernetes API machinery.

View File

@@ -1,4 +1,4 @@
package main
package discovery
import (
"testing"

6
pkg/discovery/types.go Normal file
View File

@@ -0,0 +1,6 @@
package discovery
type Discovery interface {
Discover() ([]string, error)
Watch() (<-chan HostChange, error)
}

View File

@@ -1,8 +1,8 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.9
// protoc v6.32.1
// source: proto/control_plane.proto
// protoc-gen-go v1.36.5
// protoc v5.29.3
// source: control_plane.proto
package messages
@@ -30,7 +30,7 @@ type Empty struct {
func (x *Empty) Reset() {
*x = Empty{}
mi := &file_proto_control_plane_proto_msgTypes[0]
mi := &file_control_plane_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -42,7 +42,7 @@ func (x *Empty) String() string {
func (*Empty) ProtoMessage() {}
func (x *Empty) ProtoReflect() protoreflect.Message {
mi := &file_proto_control_plane_proto_msgTypes[0]
mi := &file_control_plane_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -55,7 +55,7 @@ func (x *Empty) ProtoReflect() protoreflect.Message {
// Deprecated: Use Empty.ProtoReflect.Descriptor instead.
func (*Empty) Descriptor() ([]byte, []int) {
return file_proto_control_plane_proto_rawDescGZIP(), []int{0}
return file_control_plane_proto_rawDescGZIP(), []int{0}
}
// Ping reply includes responding host and its current unix time (seconds).
@@ -69,7 +69,7 @@ type PingReply struct {
func (x *PingReply) Reset() {
*x = PingReply{}
mi := &file_proto_control_plane_proto_msgTypes[1]
mi := &file_control_plane_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -81,7 +81,7 @@ func (x *PingReply) String() string {
func (*PingReply) ProtoMessage() {}
func (x *PingReply) ProtoReflect() protoreflect.Message {
mi := &file_proto_control_plane_proto_msgTypes[1]
mi := &file_control_plane_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -94,7 +94,7 @@ func (x *PingReply) ProtoReflect() protoreflect.Message {
// Deprecated: Use PingReply.ProtoReflect.Descriptor instead.
func (*PingReply) Descriptor() ([]byte, []int) {
return file_proto_control_plane_proto_rawDescGZIP(), []int{1}
return file_control_plane_proto_rawDescGZIP(), []int{1}
}
func (x *PingReply) GetHost() string {
@@ -121,7 +121,7 @@ type NegotiateRequest struct {
func (x *NegotiateRequest) Reset() {
*x = NegotiateRequest{}
mi := &file_proto_control_plane_proto_msgTypes[2]
mi := &file_control_plane_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -133,7 +133,7 @@ func (x *NegotiateRequest) String() string {
func (*NegotiateRequest) ProtoMessage() {}
func (x *NegotiateRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_control_plane_proto_msgTypes[2]
mi := &file_control_plane_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -146,7 +146,7 @@ func (x *NegotiateRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use NegotiateRequest.ProtoReflect.Descriptor instead.
func (*NegotiateRequest) Descriptor() ([]byte, []int) {
return file_proto_control_plane_proto_rawDescGZIP(), []int{2}
return file_control_plane_proto_rawDescGZIP(), []int{2}
}
func (x *NegotiateRequest) GetKnownHosts() []string {
@@ -166,7 +166,7 @@ type NegotiateReply struct {
func (x *NegotiateReply) Reset() {
*x = NegotiateReply{}
mi := &file_proto_control_plane_proto_msgTypes[3]
mi := &file_control_plane_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -178,7 +178,7 @@ func (x *NegotiateReply) String() string {
func (*NegotiateReply) ProtoMessage() {}
func (x *NegotiateReply) ProtoReflect() protoreflect.Message {
mi := &file_proto_control_plane_proto_msgTypes[3]
mi := &file_control_plane_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -191,7 +191,7 @@ func (x *NegotiateReply) ProtoReflect() protoreflect.Message {
// Deprecated: Use NegotiateReply.ProtoReflect.Descriptor instead.
func (*NegotiateReply) Descriptor() ([]byte, []int) {
return file_proto_control_plane_proto_rawDescGZIP(), []int{3}
return file_control_plane_proto_rawDescGZIP(), []int{3}
}
func (x *NegotiateReply) GetHosts() []string {
@@ -202,28 +202,28 @@ func (x *NegotiateReply) GetHosts() []string {
}
// CartIdsReply returns the list of cart IDs (string form) currently owned locally.
type CartIdsReply struct {
type ActorIdsReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
CartIds []uint64 `protobuf:"varint,1,rep,packed,name=cart_ids,json=cartIds,proto3" json:"cart_ids,omitempty"`
Ids []uint64 `protobuf:"varint,1,rep,packed,name=ids,proto3" json:"ids,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CartIdsReply) Reset() {
*x = CartIdsReply{}
mi := &file_proto_control_plane_proto_msgTypes[4]
func (x *ActorIdsReply) Reset() {
*x = ActorIdsReply{}
mi := &file_control_plane_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CartIdsReply) String() string {
func (x *ActorIdsReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CartIdsReply) ProtoMessage() {}
func (*ActorIdsReply) ProtoMessage() {}
func (x *CartIdsReply) ProtoReflect() protoreflect.Message {
mi := &file_proto_control_plane_proto_msgTypes[4]
func (x *ActorIdsReply) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -234,14 +234,14 @@ func (x *CartIdsReply) ProtoReflect() protoreflect.Message {
return mi.MessageOf(x)
}
// Deprecated: Use CartIdsReply.ProtoReflect.Descriptor instead.
func (*CartIdsReply) Descriptor() ([]byte, []int) {
return file_proto_control_plane_proto_rawDescGZIP(), []int{4}
// Deprecated: Use ActorIdsReply.ProtoReflect.Descriptor instead.
func (*ActorIdsReply) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{4}
}
func (x *CartIdsReply) GetCartIds() []uint64 {
func (x *ActorIdsReply) GetIds() []uint64 {
if x != nil {
return x.CartIds
return x.Ids
}
return nil
}
@@ -257,7 +257,7 @@ type OwnerChangeAck struct {
func (x *OwnerChangeAck) Reset() {
*x = OwnerChangeAck{}
mi := &file_proto_control_plane_proto_msgTypes[5]
mi := &file_control_plane_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -269,7 +269,7 @@ func (x *OwnerChangeAck) String() string {
func (*OwnerChangeAck) ProtoMessage() {}
func (x *OwnerChangeAck) ProtoReflect() protoreflect.Message {
mi := &file_proto_control_plane_proto_msgTypes[5]
mi := &file_control_plane_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -282,7 +282,7 @@ func (x *OwnerChangeAck) ProtoReflect() protoreflect.Message {
// Deprecated: Use OwnerChangeAck.ProtoReflect.Descriptor instead.
func (*OwnerChangeAck) Descriptor() ([]byte, []int) {
return file_proto_control_plane_proto_rawDescGZIP(), []int{5}
return file_control_plane_proto_rawDescGZIP(), []int{5}
}
func (x *OwnerChangeAck) GetAccepted() bool {
@@ -309,7 +309,7 @@ type ClosingNotice struct {
func (x *ClosingNotice) Reset() {
*x = ClosingNotice{}
mi := &file_proto_control_plane_proto_msgTypes[6]
mi := &file_control_plane_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -321,7 +321,7 @@ func (x *ClosingNotice) String() string {
func (*ClosingNotice) ProtoMessage() {}
func (x *ClosingNotice) ProtoReflect() protoreflect.Message {
mi := &file_proto_control_plane_proto_msgTypes[6]
mi := &file_control_plane_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -334,7 +334,7 @@ func (x *ClosingNotice) ProtoReflect() protoreflect.Message {
// Deprecated: Use ClosingNotice.ProtoReflect.Descriptor instead.
func (*ClosingNotice) Descriptor() ([]byte, []int) {
return file_proto_control_plane_proto_rawDescGZIP(), []int{6}
return file_control_plane_proto_rawDescGZIP(), []int{6}
}
func (x *ClosingNotice) GetHost() string {
@@ -349,14 +349,14 @@ func (x *ClosingNotice) GetHost() string {
type OwnershipAnnounce struct {
state protoimpl.MessageState `protogen:"open.v1"`
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` // announcing host
CartIds []uint64 `protobuf:"varint,2,rep,packed,name=cart_ids,json=cartIds,proto3" json:"cart_ids,omitempty"` // newly claimed cart ids
Ids []uint64 `protobuf:"varint,2,rep,packed,name=ids,proto3" json:"ids,omitempty"` // newly claimed cart ids
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *OwnershipAnnounce) Reset() {
*x = OwnershipAnnounce{}
mi := &file_proto_control_plane_proto_msgTypes[7]
mi := &file_control_plane_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -368,7 +368,7 @@ func (x *OwnershipAnnounce) String() string {
func (*OwnershipAnnounce) ProtoMessage() {}
func (x *OwnershipAnnounce) ProtoReflect() protoreflect.Message {
mi := &file_proto_control_plane_proto_msgTypes[7]
mi := &file_control_plane_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -381,7 +381,7 @@ func (x *OwnershipAnnounce) ProtoReflect() protoreflect.Message {
// Deprecated: Use OwnershipAnnounce.ProtoReflect.Descriptor instead.
func (*OwnershipAnnounce) Descriptor() ([]byte, []int) {
return file_proto_control_plane_proto_rawDescGZIP(), []int{7}
return file_control_plane_proto_rawDescGZIP(), []int{7}
}
func (x *OwnershipAnnounce) GetHost() string {
@@ -391,9 +391,9 @@ func (x *OwnershipAnnounce) GetHost() string {
return ""
}
func (x *OwnershipAnnounce) GetCartIds() []uint64 {
func (x *OwnershipAnnounce) GetIds() []uint64 {
if x != nil {
return x.CartIds
return x.Ids
}
return nil
}
@@ -402,14 +402,14 @@ func (x *OwnershipAnnounce) GetCartIds() []uint64 {
type ExpiryAnnounce struct {
state protoimpl.MessageState `protogen:"open.v1"`
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
CartIds []uint64 `protobuf:"varint,2,rep,packed,name=cart_ids,json=cartIds,proto3" json:"cart_ids,omitempty"`
Ids []uint64 `protobuf:"varint,2,rep,packed,name=ids,proto3" json:"ids,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExpiryAnnounce) Reset() {
*x = ExpiryAnnounce{}
mi := &file_proto_control_plane_proto_msgTypes[8]
mi := &file_control_plane_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -421,7 +421,7 @@ func (x *ExpiryAnnounce) String() string {
func (*ExpiryAnnounce) ProtoMessage() {}
func (x *ExpiryAnnounce) ProtoReflect() protoreflect.Message {
mi := &file_proto_control_plane_proto_msgTypes[8]
mi := &file_control_plane_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -434,7 +434,7 @@ func (x *ExpiryAnnounce) ProtoReflect() protoreflect.Message {
// Deprecated: Use ExpiryAnnounce.ProtoReflect.Descriptor instead.
func (*ExpiryAnnounce) Descriptor() ([]byte, []int) {
return file_proto_control_plane_proto_rawDescGZIP(), []int{8}
return file_control_plane_proto_rawDescGZIP(), []int{8}
}
func (x *ExpiryAnnounce) GetHost() string {
@@ -444,83 +444,109 @@ func (x *ExpiryAnnounce) GetHost() string {
return ""
}
func (x *ExpiryAnnounce) GetCartIds() []uint64 {
func (x *ExpiryAnnounce) GetIds() []uint64 {
if x != nil {
return x.CartIds
return x.Ids
}
return nil
}
var File_proto_control_plane_proto protoreflect.FileDescriptor
var File_control_plane_proto protoreflect.FileDescriptor
const file_proto_control_plane_proto_rawDesc = "" +
"\n" +
"\x19proto/control_plane.proto\x12\bmessages\"\a\n" +
"\x05Empty\"<\n" +
"\tPingReply\x12\x12\n" +
"\x04host\x18\x01 \x01(\tR\x04host\x12\x1b\n" +
"\tunix_time\x18\x02 \x01(\x03R\bunixTime\"3\n" +
"\x10NegotiateRequest\x12\x1f\n" +
"\vknown_hosts\x18\x01 \x03(\tR\n" +
"knownHosts\"&\n" +
"\x0eNegotiateReply\x12\x14\n" +
"\x05hosts\x18\x01 \x03(\tR\x05hosts\")\n" +
"\fCartIdsReply\x12\x19\n" +
"\bcart_ids\x18\x01 \x03(\x04R\acartIds\"F\n" +
"\x0eOwnerChangeAck\x12\x1a\n" +
"\baccepted\x18\x01 \x01(\bR\baccepted\x12\x18\n" +
"\amessage\x18\x02 \x01(\tR\amessage\"#\n" +
"\rClosingNotice\x12\x12\n" +
"\x04host\x18\x01 \x01(\tR\x04host\"B\n" +
"\x11OwnershipAnnounce\x12\x12\n" +
"\x04host\x18\x01 \x01(\tR\x04host\x12\x19\n" +
"\bcart_ids\x18\x02 \x03(\x04R\acartIds\"?\n" +
"\x0eExpiryAnnounce\x12\x12\n" +
"\x04host\x18\x01 \x01(\tR\x04host\x12\x19\n" +
"\bcart_ids\x18\x02 \x03(\x04R\acartIds2\x86\x03\n" +
"\fControlPlane\x12,\n" +
"\x04Ping\x12\x0f.messages.Empty\x1a\x13.messages.PingReply\x12A\n" +
"\tNegotiate\x12\x1a.messages.NegotiateRequest\x1a\x18.messages.NegotiateReply\x125\n" +
"\n" +
"GetCartIds\x12\x0f.messages.Empty\x1a\x16.messages.CartIdsReply\x12J\n" +
"\x11AnnounceOwnership\x12\x1b.messages.OwnershipAnnounce\x1a\x18.messages.OwnerChangeAck\x12D\n" +
"\x0eAnnounceExpiry\x12\x18.messages.ExpiryAnnounce\x1a\x18.messages.OwnerChangeAck\x12<\n" +
"\aClosing\x12\x17.messages.ClosingNotice\x1a\x18.messages.OwnerChangeAckB.Z,git.tornberg.me/go-cart-actor/proto;messagesb\x06proto3"
var file_control_plane_proto_rawDesc = string([]byte{
0x0a, 0x13, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22,
0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x3c, 0x0a, 0x09, 0x50, 0x69, 0x6e, 0x67,
0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20,
0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x75, 0x6e, 0x69,
0x78, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x75, 0x6e,
0x69, 0x78, 0x54, 0x69, 0x6d, 0x65, 0x22, 0x33, 0x0a, 0x10, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69,
0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x6b, 0x6e,
0x6f, 0x77, 0x6e, 0x5f, 0x68, 0x6f, 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52,
0x0a, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x48, 0x6f, 0x73, 0x74, 0x73, 0x22, 0x26, 0x0a, 0x0e, 0x4e,
0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x14, 0x0a,
0x05, 0x68, 0x6f, 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x68, 0x6f,
0x73, 0x74, 0x73, 0x22, 0x21, 0x0a, 0x0d, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x73, 0x52,
0x65, 0x70, 0x6c, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28,
0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22, 0x46, 0x0a, 0x0e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43,
0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65,
0x70, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65,
0x70, 0x74, 0x65, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18,
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x23,
0x0a, 0x0d, 0x43, 0x6c, 0x6f, 0x73, 0x69, 0x6e, 0x67, 0x4e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x12,
0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68,
0x6f, 0x73, 0x74, 0x22, 0x39, 0x0a, 0x11, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70,
0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03,
0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22, 0x36,
0x0a, 0x0e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x79, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65,
0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
0x68, 0x6f, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28,
0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x32, 0x8d, 0x03, 0x0a, 0x0c, 0x43, 0x6f, 0x6e, 0x74, 0x72,
0x6f, 0x6c, 0x50, 0x6c, 0x61, 0x6e, 0x65, 0x12, 0x2c, 0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, 0x12,
0x0f, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79,
0x1a, 0x13, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x50, 0x69, 0x6e, 0x67,
0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x41, 0x0a, 0x09, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69, 0x61,
0x74, 0x65, 0x12, 0x1a, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4e, 0x65,
0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18,
0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69,
0x61, 0x74, 0x65, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x3c, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4c,
0x6f, 0x63, 0x61, 0x6c, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x73, 0x12, 0x0f, 0x2e, 0x6d,
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x17, 0x2e,
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64,
0x73, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x4a, 0x0a, 0x11, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e,
0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x12, 0x1b, 0x2e, 0x6d, 0x65,
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70,
0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x1a, 0x18, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61,
0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x41,
0x63, 0x6b, 0x12, 0x44, 0x0a, 0x0e, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x45, 0x78,
0x70, 0x69, 0x72, 0x79, 0x12, 0x18, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
0x45, 0x78, 0x70, 0x69, 0x72, 0x79, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x1a, 0x18,
0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43,
0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x12, 0x3c, 0x0a, 0x07, 0x43, 0x6c, 0x6f, 0x73,
0x69, 0x6e, 0x67, 0x12, 0x17, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x43,
0x6c, 0x6f, 0x73, 0x69, 0x6e, 0x67, 0x4e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x1a, 0x18, 0x2e, 0x6d,
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61,
0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x42, 0x2e, 0x5a, 0x2c, 0x67, 0x69, 0x74, 0x2e, 0x74, 0x6f,
0x72, 0x6e, 0x62, 0x65, 0x72, 0x67, 0x2e, 0x6d, 0x65, 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72,
0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x3b, 0x6d, 0x65,
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
})
var (
file_proto_control_plane_proto_rawDescOnce sync.Once
file_proto_control_plane_proto_rawDescData []byte
file_control_plane_proto_rawDescOnce sync.Once
file_control_plane_proto_rawDescData []byte
)
func file_proto_control_plane_proto_rawDescGZIP() []byte {
file_proto_control_plane_proto_rawDescOnce.Do(func() {
file_proto_control_plane_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_control_plane_proto_rawDesc), len(file_proto_control_plane_proto_rawDesc)))
func file_control_plane_proto_rawDescGZIP() []byte {
file_control_plane_proto_rawDescOnce.Do(func() {
file_control_plane_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_control_plane_proto_rawDesc), len(file_control_plane_proto_rawDesc)))
})
return file_proto_control_plane_proto_rawDescData
return file_control_plane_proto_rawDescData
}
var file_proto_control_plane_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
var file_proto_control_plane_proto_goTypes = []any{
var file_control_plane_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
var file_control_plane_proto_goTypes = []any{
(*Empty)(nil), // 0: messages.Empty
(*PingReply)(nil), // 1: messages.PingReply
(*NegotiateRequest)(nil), // 2: messages.NegotiateRequest
(*NegotiateReply)(nil), // 3: messages.NegotiateReply
(*CartIdsReply)(nil), // 4: messages.CartIdsReply
(*ActorIdsReply)(nil), // 4: messages.ActorIdsReply
(*OwnerChangeAck)(nil), // 5: messages.OwnerChangeAck
(*ClosingNotice)(nil), // 6: messages.ClosingNotice
(*OwnershipAnnounce)(nil), // 7: messages.OwnershipAnnounce
(*ExpiryAnnounce)(nil), // 8: messages.ExpiryAnnounce
}
var file_proto_control_plane_proto_depIdxs = []int32{
var file_control_plane_proto_depIdxs = []int32{
0, // 0: messages.ControlPlane.Ping:input_type -> messages.Empty
2, // 1: messages.ControlPlane.Negotiate:input_type -> messages.NegotiateRequest
0, // 2: messages.ControlPlane.GetCartIds:input_type -> messages.Empty
0, // 2: messages.ControlPlane.GetLocalActorIds:input_type -> messages.Empty
7, // 3: messages.ControlPlane.AnnounceOwnership:input_type -> messages.OwnershipAnnounce
8, // 4: messages.ControlPlane.AnnounceExpiry:input_type -> messages.ExpiryAnnounce
6, // 5: messages.ControlPlane.Closing:input_type -> messages.ClosingNotice
1, // 6: messages.ControlPlane.Ping:output_type -> messages.PingReply
3, // 7: messages.ControlPlane.Negotiate:output_type -> messages.NegotiateReply
4, // 8: messages.ControlPlane.GetCartIds:output_type -> messages.CartIdsReply
4, // 8: messages.ControlPlane.GetLocalActorIds:output_type -> messages.ActorIdsReply
5, // 9: messages.ControlPlane.AnnounceOwnership:output_type -> messages.OwnerChangeAck
5, // 10: messages.ControlPlane.AnnounceExpiry:output_type -> messages.OwnerChangeAck
5, // 11: messages.ControlPlane.Closing:output_type -> messages.OwnerChangeAck
@@ -531,26 +557,26 @@ var file_proto_control_plane_proto_depIdxs = []int32{
0, // [0:0] is the sub-list for field type_name
}
func init() { file_proto_control_plane_proto_init() }
func file_proto_control_plane_proto_init() {
if File_proto_control_plane_proto != nil {
func init() { file_control_plane_proto_init() }
func file_control_plane_proto_init() {
if File_control_plane_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_control_plane_proto_rawDesc), len(file_proto_control_plane_proto_rawDesc)),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_control_plane_proto_rawDesc), len(file_control_plane_proto_rawDesc)),
NumEnums: 0,
NumMessages: 9,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_proto_control_plane_proto_goTypes,
DependencyIndexes: file_proto_control_plane_proto_depIdxs,
MessageInfos: file_proto_control_plane_proto_msgTypes,
GoTypes: file_control_plane_proto_goTypes,
DependencyIndexes: file_control_plane_proto_depIdxs,
MessageInfos: file_control_plane_proto_msgTypes,
}.Build()
File_proto_control_plane_proto = out.File
file_proto_control_plane_proto_goTypes = nil
file_proto_control_plane_proto_depIdxs = nil
File_control_plane_proto = out.File
file_control_plane_proto_goTypes = nil
file_control_plane_proto_depIdxs = nil
}

View File

@@ -1,8 +1,8 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v6.32.1
// source: proto/control_plane.proto
// - protoc v5.29.3
// source: control_plane.proto
package messages
@@ -21,7 +21,7 @@ const _ = grpc.SupportPackageIsVersion9
const (
ControlPlane_Ping_FullMethodName = "/messages.ControlPlane/Ping"
ControlPlane_Negotiate_FullMethodName = "/messages.ControlPlane/Negotiate"
ControlPlane_GetCartIds_FullMethodName = "/messages.ControlPlane/GetCartIds"
ControlPlane_GetLocalActorIds_FullMethodName = "/messages.ControlPlane/GetLocalActorIds"
ControlPlane_AnnounceOwnership_FullMethodName = "/messages.ControlPlane/AnnounceOwnership"
ControlPlane_AnnounceExpiry_FullMethodName = "/messages.ControlPlane/AnnounceExpiry"
ControlPlane_Closing_FullMethodName = "/messages.ControlPlane/Closing"
@@ -38,7 +38,7 @@ type ControlPlaneClient interface {
// Negotiate merges host views; used during discovery & convergence.
Negotiate(ctx context.Context, in *NegotiateRequest, opts ...grpc.CallOption) (*NegotiateReply, error)
// GetCartIds lists currently owned cart IDs on this node.
GetCartIds(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*CartIdsReply, error)
GetLocalActorIds(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*ActorIdsReply, error)
// Ownership announcement: first-touch claim broadcast (idempotent; best-effort).
AnnounceOwnership(ctx context.Context, in *OwnershipAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error)
// Expiry announcement: drop remote ownership hints when local TTL expires.
@@ -75,10 +75,10 @@ func (c *controlPlaneClient) Negotiate(ctx context.Context, in *NegotiateRequest
return out, nil
}
func (c *controlPlaneClient) GetCartIds(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*CartIdsReply, error) {
func (c *controlPlaneClient) GetLocalActorIds(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*ActorIdsReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CartIdsReply)
err := c.cc.Invoke(ctx, ControlPlane_GetCartIds_FullMethodName, in, out, cOpts...)
out := new(ActorIdsReply)
err := c.cc.Invoke(ctx, ControlPlane_GetLocalActorIds_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
@@ -126,7 +126,7 @@ type ControlPlaneServer interface {
// Negotiate merges host views; used during discovery & convergence.
Negotiate(context.Context, *NegotiateRequest) (*NegotiateReply, error)
// GetCartIds lists currently owned cart IDs on this node.
GetCartIds(context.Context, *Empty) (*CartIdsReply, error)
GetLocalActorIds(context.Context, *Empty) (*ActorIdsReply, error)
// Ownership announcement: first-touch claim broadcast (idempotent; best-effort).
AnnounceOwnership(context.Context, *OwnershipAnnounce) (*OwnerChangeAck, error)
// Expiry announcement: drop remote ownership hints when local TTL expires.
@@ -149,8 +149,8 @@ func (UnimplementedControlPlaneServer) Ping(context.Context, *Empty) (*PingReply
func (UnimplementedControlPlaneServer) Negotiate(context.Context, *NegotiateRequest) (*NegotiateReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method Negotiate not implemented")
}
func (UnimplementedControlPlaneServer) GetCartIds(context.Context, *Empty) (*CartIdsReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetCartIds not implemented")
func (UnimplementedControlPlaneServer) GetLocalActorIds(context.Context, *Empty) (*ActorIdsReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetLocalActorIds not implemented")
}
func (UnimplementedControlPlaneServer) AnnounceOwnership(context.Context, *OwnershipAnnounce) (*OwnerChangeAck, error) {
return nil, status.Errorf(codes.Unimplemented, "method AnnounceOwnership not implemented")
@@ -218,20 +218,20 @@ func _ControlPlane_Negotiate_Handler(srv interface{}, ctx context.Context, dec f
return interceptor(ctx, in, info, handler)
}
func _ControlPlane_GetCartIds_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
func _ControlPlane_GetLocalActorIds_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(Empty)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ControlPlaneServer).GetCartIds(ctx, in)
return srv.(ControlPlaneServer).GetLocalActorIds(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ControlPlane_GetCartIds_FullMethodName,
FullMethod: ControlPlane_GetLocalActorIds_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ControlPlaneServer).GetCartIds(ctx, req.(*Empty))
return srv.(ControlPlaneServer).GetLocalActorIds(ctx, req.(*Empty))
}
return interceptor(ctx, in, info, handler)
}
@@ -306,8 +306,8 @@ var ControlPlane_ServiceDesc = grpc.ServiceDesc{
Handler: _ControlPlane_Negotiate_Handler,
},
{
MethodName: "GetCartIds",
Handler: _ControlPlane_GetCartIds_Handler,
MethodName: "GetLocalActorIds",
Handler: _ControlPlane_GetLocalActorIds_Handler,
},
{
MethodName: "AnnounceOwnership",
@@ -323,5 +323,5 @@ var ControlPlane_ServiceDesc = grpc.ServiceDesc{
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/control_plane.proto",
Metadata: "control_plane.proto",
}

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.10
// protoc v3.21.12
// protoc-gen-go v1.36.5
// protoc v5.29.3
// source: messages.proto
package messages
@@ -21,30 +21,26 @@ const (
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type AddRequest struct {
type ClearCartRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Quantity int32 `protobuf:"varint,1,opt,name=quantity,proto3" json:"quantity,omitempty"`
Sku string `protobuf:"bytes,2,opt,name=sku,proto3" json:"sku,omitempty"`
Country string `protobuf:"bytes,3,opt,name=country,proto3" json:"country,omitempty"`
StoreId *string `protobuf:"bytes,4,opt,name=storeId,proto3,oneof" json:"storeId,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AddRequest) Reset() {
*x = AddRequest{}
func (x *ClearCartRequest) Reset() {
*x = ClearCartRequest{}
mi := &file_messages_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AddRequest) String() string {
func (x *ClearCartRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AddRequest) ProtoMessage() {}
func (*ClearCartRequest) ProtoMessage() {}
func (x *AddRequest) ProtoReflect() protoreflect.Message {
func (x *ClearCartRequest) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
@@ -56,86 +52,14 @@ func (x *AddRequest) ProtoReflect() protoreflect.Message {
return mi.MessageOf(x)
}
// Deprecated: Use AddRequest.ProtoReflect.Descriptor instead.
func (*AddRequest) Descriptor() ([]byte, []int) {
// Deprecated: Use ClearCartRequest.ProtoReflect.Descriptor instead.
func (*ClearCartRequest) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{0}
}
func (x *AddRequest) GetQuantity() int32 {
if x != nil {
return x.Quantity
}
return 0
}
func (x *AddRequest) GetSku() string {
if x != nil {
return x.Sku
}
return ""
}
func (x *AddRequest) GetCountry() string {
if x != nil {
return x.Country
}
return ""
}
func (x *AddRequest) GetStoreId() string {
if x != nil && x.StoreId != nil {
return *x.StoreId
}
return ""
}
type SetCartRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Items []*AddRequest `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SetCartRequest) Reset() {
*x = SetCartRequest{}
mi := &file_messages_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SetCartRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SetCartRequest) ProtoMessage() {}
func (x *SetCartRequest) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SetCartRequest.ProtoReflect.Descriptor instead.
func (*SetCartRequest) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{1}
}
func (x *SetCartRequest) GetItems() []*AddRequest {
if x != nil {
return x.Items
}
return nil
}
type AddItem struct {
state protoimpl.MessageState `protogen:"open.v1"`
ItemId int64 `protobuf:"varint,1,opt,name=item_id,json=itemId,proto3" json:"item_id,omitempty"`
ItemId uint32 `protobuf:"varint,1,opt,name=item_id,json=itemId,proto3" json:"item_id,omitempty"`
Quantity int32 `protobuf:"varint,2,opt,name=quantity,proto3" json:"quantity,omitempty"`
Price int64 `protobuf:"varint,3,opt,name=price,proto3" json:"price,omitempty"`
OrgPrice int64 `protobuf:"varint,9,opt,name=orgPrice,proto3" json:"orgPrice,omitempty"`
@@ -157,13 +81,14 @@ type AddItem struct {
Country string `protobuf:"bytes,21,opt,name=country,proto3" json:"country,omitempty"`
Outlet *string `protobuf:"bytes,12,opt,name=outlet,proto3,oneof" json:"outlet,omitempty"`
StoreId *string `protobuf:"bytes,22,opt,name=storeId,proto3,oneof" json:"storeId,omitempty"`
ParentId *uint32 `protobuf:"varint,23,opt,name=parentId,proto3,oneof" json:"parentId,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AddItem) Reset() {
*x = AddItem{}
mi := &file_messages_proto_msgTypes[2]
mi := &file_messages_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -175,7 +100,7 @@ func (x *AddItem) String() string {
func (*AddItem) ProtoMessage() {}
func (x *AddItem) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[2]
mi := &file_messages_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -188,10 +113,10 @@ func (x *AddItem) ProtoReflect() protoreflect.Message {
// Deprecated: Use AddItem.ProtoReflect.Descriptor instead.
func (*AddItem) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{2}
return file_messages_proto_rawDescGZIP(), []int{1}
}
func (x *AddItem) GetItemId() int64 {
func (x *AddItem) GetItemId() uint32 {
if x != nil {
return x.ItemId
}
@@ -345,16 +270,23 @@ func (x *AddItem) GetStoreId() string {
return ""
}
func (x *AddItem) GetParentId() uint32 {
if x != nil && x.ParentId != nil {
return *x.ParentId
}
return 0
}
type RemoveItem struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id int64 `protobuf:"varint,1,opt,name=Id,proto3" json:"Id,omitempty"`
Id uint32 `protobuf:"varint,1,opt,name=Id,proto3" json:"Id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RemoveItem) Reset() {
*x = RemoveItem{}
mi := &file_messages_proto_msgTypes[3]
mi := &file_messages_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -366,7 +298,7 @@ func (x *RemoveItem) String() string {
func (*RemoveItem) ProtoMessage() {}
func (x *RemoveItem) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[3]
mi := &file_messages_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -379,10 +311,10 @@ func (x *RemoveItem) ProtoReflect() protoreflect.Message {
// Deprecated: Use RemoveItem.ProtoReflect.Descriptor instead.
func (*RemoveItem) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{3}
return file_messages_proto_rawDescGZIP(), []int{2}
}
func (x *RemoveItem) GetId() int64 {
func (x *RemoveItem) GetId() uint32 {
if x != nil {
return x.Id
}
@@ -391,7 +323,7 @@ func (x *RemoveItem) GetId() int64 {
type ChangeQuantity struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
Id uint32 `protobuf:"varint,1,opt,name=Id,proto3" json:"Id,omitempty"`
Quantity int32 `protobuf:"varint,2,opt,name=quantity,proto3" json:"quantity,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
@@ -399,7 +331,7 @@ type ChangeQuantity struct {
func (x *ChangeQuantity) Reset() {
*x = ChangeQuantity{}
mi := &file_messages_proto_msgTypes[4]
mi := &file_messages_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -411,7 +343,7 @@ func (x *ChangeQuantity) String() string {
func (*ChangeQuantity) ProtoMessage() {}
func (x *ChangeQuantity) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[4]
mi := &file_messages_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -424,10 +356,10 @@ func (x *ChangeQuantity) ProtoReflect() protoreflect.Message {
// Deprecated: Use ChangeQuantity.ProtoReflect.Descriptor instead.
func (*ChangeQuantity) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{4}
return file_messages_proto_rawDescGZIP(), []int{3}
}
func (x *ChangeQuantity) GetId() int64 {
func (x *ChangeQuantity) GetId() uint32 {
if x != nil {
return x.Id
}
@@ -444,7 +376,7 @@ func (x *ChangeQuantity) GetQuantity() int32 {
type SetDelivery struct {
state protoimpl.MessageState `protogen:"open.v1"`
Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"`
Items []int64 `protobuf:"varint,2,rep,packed,name=items,proto3" json:"items,omitempty"`
Items []uint32 `protobuf:"varint,2,rep,packed,name=items,proto3" json:"items,omitempty"`
PickupPoint *PickupPoint `protobuf:"bytes,3,opt,name=pickupPoint,proto3,oneof" json:"pickupPoint,omitempty"`
Country string `protobuf:"bytes,4,opt,name=country,proto3" json:"country,omitempty"`
Zip string `protobuf:"bytes,5,opt,name=zip,proto3" json:"zip,omitempty"`
@@ -456,7 +388,7 @@ type SetDelivery struct {
func (x *SetDelivery) Reset() {
*x = SetDelivery{}
mi := &file_messages_proto_msgTypes[5]
mi := &file_messages_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -468,7 +400,7 @@ func (x *SetDelivery) String() string {
func (*SetDelivery) ProtoMessage() {}
func (x *SetDelivery) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[5]
mi := &file_messages_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -481,7 +413,7 @@ func (x *SetDelivery) ProtoReflect() protoreflect.Message {
// Deprecated: Use SetDelivery.ProtoReflect.Descriptor instead.
func (*SetDelivery) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{5}
return file_messages_proto_rawDescGZIP(), []int{4}
}
func (x *SetDelivery) GetProvider() string {
@@ -491,7 +423,7 @@ func (x *SetDelivery) GetProvider() string {
return ""
}
func (x *SetDelivery) GetItems() []int64 {
func (x *SetDelivery) GetItems() []uint32 {
if x != nil {
return x.Items
}
@@ -535,7 +467,7 @@ func (x *SetDelivery) GetCity() string {
type SetPickupPoint struct {
state protoimpl.MessageState `protogen:"open.v1"`
DeliveryId int64 `protobuf:"varint,1,opt,name=deliveryId,proto3" json:"deliveryId,omitempty"`
DeliveryId uint32 `protobuf:"varint,1,opt,name=deliveryId,proto3" json:"deliveryId,omitempty"`
Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"`
Name *string `protobuf:"bytes,3,opt,name=name,proto3,oneof" json:"name,omitempty"`
Address *string `protobuf:"bytes,4,opt,name=address,proto3,oneof" json:"address,omitempty"`
@@ -548,7 +480,7 @@ type SetPickupPoint struct {
func (x *SetPickupPoint) Reset() {
*x = SetPickupPoint{}
mi := &file_messages_proto_msgTypes[6]
mi := &file_messages_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -560,7 +492,7 @@ func (x *SetPickupPoint) String() string {
func (*SetPickupPoint) ProtoMessage() {}
func (x *SetPickupPoint) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[6]
mi := &file_messages_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -573,10 +505,10 @@ func (x *SetPickupPoint) ProtoReflect() protoreflect.Message {
// Deprecated: Use SetPickupPoint.ProtoReflect.Descriptor instead.
func (*SetPickupPoint) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{6}
return file_messages_proto_rawDescGZIP(), []int{5}
}
func (x *SetPickupPoint) GetDeliveryId() int64 {
func (x *SetPickupPoint) GetDeliveryId() uint32 {
if x != nil {
return x.DeliveryId
}
@@ -639,7 +571,7 @@ type PickupPoint struct {
func (x *PickupPoint) Reset() {
*x = PickupPoint{}
mi := &file_messages_proto_msgTypes[7]
mi := &file_messages_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -651,7 +583,7 @@ func (x *PickupPoint) String() string {
func (*PickupPoint) ProtoMessage() {}
func (x *PickupPoint) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[7]
mi := &file_messages_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -664,7 +596,7 @@ func (x *PickupPoint) ProtoReflect() protoreflect.Message {
// Deprecated: Use PickupPoint.ProtoReflect.Descriptor instead.
func (*PickupPoint) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{7}
return file_messages_proto_rawDescGZIP(), []int{6}
}
func (x *PickupPoint) GetId() string {
@@ -711,14 +643,14 @@ func (x *PickupPoint) GetCountry() string {
type RemoveDelivery struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RemoveDelivery) Reset() {
*x = RemoveDelivery{}
mi := &file_messages_proto_msgTypes[8]
mi := &file_messages_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -730,7 +662,7 @@ func (x *RemoveDelivery) String() string {
func (*RemoveDelivery) ProtoMessage() {}
func (x *RemoveDelivery) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[8]
mi := &file_messages_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -743,10 +675,10 @@ func (x *RemoveDelivery) ProtoReflect() protoreflect.Message {
// Deprecated: Use RemoveDelivery.ProtoReflect.Descriptor instead.
func (*RemoveDelivery) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{8}
return file_messages_proto_rawDescGZIP(), []int{7}
}
func (x *RemoveDelivery) GetId() int64 {
func (x *RemoveDelivery) GetId() uint32 {
if x != nil {
return x.Id
}
@@ -767,7 +699,7 @@ type CreateCheckoutOrder struct {
func (x *CreateCheckoutOrder) Reset() {
*x = CreateCheckoutOrder{}
mi := &file_messages_proto_msgTypes[9]
mi := &file_messages_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -779,7 +711,7 @@ func (x *CreateCheckoutOrder) String() string {
func (*CreateCheckoutOrder) ProtoMessage() {}
func (x *CreateCheckoutOrder) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[9]
mi := &file_messages_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -792,7 +724,7 @@ func (x *CreateCheckoutOrder) ProtoReflect() protoreflect.Message {
// Deprecated: Use CreateCheckoutOrder.ProtoReflect.Descriptor instead.
func (*CreateCheckoutOrder) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{9}
return file_messages_proto_rawDescGZIP(), []int{8}
}
func (x *CreateCheckoutOrder) GetTerms() string {
@@ -847,7 +779,7 @@ type OrderCreated struct {
func (x *OrderCreated) Reset() {
*x = OrderCreated{}
mi := &file_messages_proto_msgTypes[10]
mi := &file_messages_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -859,7 +791,7 @@ func (x *OrderCreated) String() string {
func (*OrderCreated) ProtoMessage() {}
func (x *OrderCreated) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[10]
mi := &file_messages_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -872,7 +804,7 @@ func (x *OrderCreated) ProtoReflect() protoreflect.Message {
// Deprecated: Use OrderCreated.ProtoReflect.Descriptor instead.
func (*OrderCreated) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{10}
return file_messages_proto_rawDescGZIP(), []int{9}
}
func (x *OrderCreated) GetOrderId() string {
@@ -897,7 +829,7 @@ type Noop struct {
func (x *Noop) Reset() {
*x = Noop{}
mi := &file_messages_proto_msgTypes[11]
mi := &file_messages_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -909,7 +841,7 @@ func (x *Noop) String() string {
func (*Noop) ProtoMessage() {}
func (x *Noop) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[11]
mi := &file_messages_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -922,7 +854,7 @@ func (x *Noop) ProtoReflect() protoreflect.Message {
// Deprecated: Use Noop.ProtoReflect.Descriptor instead.
func (*Noop) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{11}
return file_messages_proto_rawDescGZIP(), []int{10}
}
type InitializeCheckout struct {
@@ -936,7 +868,7 @@ type InitializeCheckout struct {
func (x *InitializeCheckout) Reset() {
*x = InitializeCheckout{}
mi := &file_messages_proto_msgTypes[12]
mi := &file_messages_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -948,7 +880,7 @@ func (x *InitializeCheckout) String() string {
func (*InitializeCheckout) ProtoMessage() {}
func (x *InitializeCheckout) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[12]
mi := &file_messages_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -961,7 +893,7 @@ func (x *InitializeCheckout) ProtoReflect() protoreflect.Message {
// Deprecated: Use InitializeCheckout.ProtoReflect.Descriptor instead.
func (*InitializeCheckout) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{12}
return file_messages_proto_rawDescGZIP(), []int{11}
}
func (x *InitializeCheckout) GetOrderId() string {
@@ -985,120 +917,325 @@ func (x *InitializeCheckout) GetPaymentInProgress() bool {
return false
}
type VoucherRule struct {
state protoimpl.MessageState `protogen:"open.v1"`
Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"`
Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"`
Condition string `protobuf:"bytes,4,opt,name=condition,proto3" json:"condition,omitempty"`
Action string `protobuf:"bytes,5,opt,name=action,proto3" json:"action,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *VoucherRule) Reset() {
*x = VoucherRule{}
mi := &file_messages_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *VoucherRule) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*VoucherRule) ProtoMessage() {}
func (x *VoucherRule) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use VoucherRule.ProtoReflect.Descriptor instead.
func (*VoucherRule) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{12}
}
func (x *VoucherRule) GetType() string {
if x != nil {
return x.Type
}
return ""
}
func (x *VoucherRule) GetDescription() string {
if x != nil {
return x.Description
}
return ""
}
func (x *VoucherRule) GetCondition() string {
if x != nil {
return x.Condition
}
return ""
}
func (x *VoucherRule) GetAction() string {
if x != nil {
return x.Action
}
return ""
}
type AddVoucher struct {
state protoimpl.MessageState `protogen:"open.v1"`
Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"`
Value int64 `protobuf:"varint,2,opt,name=value,proto3" json:"value,omitempty"`
VoucherRules []*VoucherRule `protobuf:"bytes,3,rep,name=voucherRules,proto3" json:"voucherRules,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AddVoucher) Reset() {
*x = AddVoucher{}
mi := &file_messages_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AddVoucher) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AddVoucher) ProtoMessage() {}
func (x *AddVoucher) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use AddVoucher.ProtoReflect.Descriptor instead.
func (*AddVoucher) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{13}
}
func (x *AddVoucher) GetCode() string {
if x != nil {
return x.Code
}
return ""
}
func (x *AddVoucher) GetValue() int64 {
if x != nil {
return x.Value
}
return 0
}
func (x *AddVoucher) GetVoucherRules() []*VoucherRule {
if x != nil {
return x.VoucherRules
}
return nil
}
type RemoveVoucher struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RemoveVoucher) Reset() {
*x = RemoveVoucher{}
mi := &file_messages_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RemoveVoucher) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RemoveVoucher) ProtoMessage() {}
func (x *RemoveVoucher) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[14]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RemoveVoucher.ProtoReflect.Descriptor instead.
func (*RemoveVoucher) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{14}
}
func (x *RemoveVoucher) GetId() uint32 {
if x != nil {
return x.Id
}
return 0
}
var File_messages_proto protoreflect.FileDescriptor
const file_messages_proto_rawDesc = "" +
"\n" +
"\x0emessages.proto\x12\bmessages\"\x7f\n" +
"\n" +
"AddRequest\x12\x1a\n" +
"\bquantity\x18\x01 \x01(\x05R\bquantity\x12\x10\n" +
"\x03sku\x18\x02 \x01(\tR\x03sku\x12\x18\n" +
"\acountry\x18\x03 \x01(\tR\acountry\x12\x1d\n" +
"\astoreId\x18\x04 \x01(\tH\x00R\astoreId\x88\x01\x01B\n" +
"\n" +
"\b_storeId\"<\n" +
"\x0eSetCartRequest\x12*\n" +
"\x05items\x18\x01 \x03(\v2\x14.messages.AddRequestR\x05items\"\xe9\x04\n" +
"\aAddItem\x12\x17\n" +
"\aitem_id\x18\x01 \x01(\x03R\x06itemId\x12\x1a\n" +
"\bquantity\x18\x02 \x01(\x05R\bquantity\x12\x14\n" +
"\x05price\x18\x03 \x01(\x03R\x05price\x12\x1a\n" +
"\borgPrice\x18\t \x01(\x03R\borgPrice\x12\x10\n" +
"\x03sku\x18\x04 \x01(\tR\x03sku\x12\x12\n" +
"\x04name\x18\x05 \x01(\tR\x04name\x12\x14\n" +
"\x05image\x18\x06 \x01(\tR\x05image\x12\x14\n" +
"\x05stock\x18\a \x01(\x05R\x05stock\x12\x10\n" +
"\x03tax\x18\b \x01(\x05R\x03tax\x12\x14\n" +
"\x05brand\x18\r \x01(\tR\x05brand\x12\x1a\n" +
"\bcategory\x18\x0e \x01(\tR\bcategory\x12\x1c\n" +
"\tcategory2\x18\x0f \x01(\tR\tcategory2\x12\x1c\n" +
"\tcategory3\x18\x10 \x01(\tR\tcategory3\x12\x1c\n" +
"\tcategory4\x18\x11 \x01(\tR\tcategory4\x12\x1c\n" +
"\tcategory5\x18\x12 \x01(\tR\tcategory5\x12\x1e\n" +
"\n" +
"disclaimer\x18\n" +
" \x01(\tR\n" +
"disclaimer\x12 \n" +
"\varticleType\x18\v \x01(\tR\varticleType\x12\x1a\n" +
"\bsellerId\x18\x13 \x01(\tR\bsellerId\x12\x1e\n" +
"\n" +
"sellerName\x18\x14 \x01(\tR\n" +
"sellerName\x12\x18\n" +
"\acountry\x18\x15 \x01(\tR\acountry\x12\x1b\n" +
"\x06outlet\x18\f \x01(\tH\x00R\x06outlet\x88\x01\x01\x12\x1d\n" +
"\astoreId\x18\x16 \x01(\tH\x01R\astoreId\x88\x01\x01B\t\n" +
"\a_outletB\n" +
"\n" +
"\b_storeId\"\x1c\n" +
"\n" +
"RemoveItem\x12\x0e\n" +
"\x02Id\x18\x01 \x01(\x03R\x02Id\"<\n" +
"\x0eChangeQuantity\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x03R\x02id\x12\x1a\n" +
"\bquantity\x18\x02 \x01(\x05R\bquantity\"\x86\x02\n" +
"\vSetDelivery\x12\x1a\n" +
"\bprovider\x18\x01 \x01(\tR\bprovider\x12\x14\n" +
"\x05items\x18\x02 \x03(\x03R\x05items\x12<\n" +
"\vpickupPoint\x18\x03 \x01(\v2\x15.messages.PickupPointH\x00R\vpickupPoint\x88\x01\x01\x12\x18\n" +
"\acountry\x18\x04 \x01(\tR\acountry\x12\x10\n" +
"\x03zip\x18\x05 \x01(\tR\x03zip\x12\x1d\n" +
"\aaddress\x18\x06 \x01(\tH\x01R\aaddress\x88\x01\x01\x12\x17\n" +
"\x04city\x18\a \x01(\tH\x02R\x04city\x88\x01\x01B\x0e\n" +
"\f_pickupPointB\n" +
"\n" +
"\b_addressB\a\n" +
"\x05_city\"\xf9\x01\n" +
"\x0eSetPickupPoint\x12\x1e\n" +
"\n" +
"deliveryId\x18\x01 \x01(\x03R\n" +
"deliveryId\x12\x0e\n" +
"\x02id\x18\x02 \x01(\tR\x02id\x12\x17\n" +
"\x04name\x18\x03 \x01(\tH\x00R\x04name\x88\x01\x01\x12\x1d\n" +
"\aaddress\x18\x04 \x01(\tH\x01R\aaddress\x88\x01\x01\x12\x17\n" +
"\x04city\x18\x05 \x01(\tH\x02R\x04city\x88\x01\x01\x12\x15\n" +
"\x03zip\x18\x06 \x01(\tH\x03R\x03zip\x88\x01\x01\x12\x1d\n" +
"\acountry\x18\a \x01(\tH\x04R\acountry\x88\x01\x01B\a\n" +
"\x05_nameB\n" +
"\n" +
"\b_addressB\a\n" +
"\x05_cityB\x06\n" +
"\x04_zipB\n" +
"\n" +
"\b_country\"\xd6\x01\n" +
"\vPickupPoint\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x17\n" +
"\x04name\x18\x02 \x01(\tH\x00R\x04name\x88\x01\x01\x12\x1d\n" +
"\aaddress\x18\x03 \x01(\tH\x01R\aaddress\x88\x01\x01\x12\x17\n" +
"\x04city\x18\x04 \x01(\tH\x02R\x04city\x88\x01\x01\x12\x15\n" +
"\x03zip\x18\x05 \x01(\tH\x03R\x03zip\x88\x01\x01\x12\x1d\n" +
"\acountry\x18\x06 \x01(\tH\x04R\acountry\x88\x01\x01B\a\n" +
"\x05_nameB\n" +
"\n" +
"\b_addressB\a\n" +
"\x05_cityB\x06\n" +
"\x04_zipB\n" +
"\n" +
"\b_country\" \n" +
"\x0eRemoveDelivery\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x03R\x02id\"\xb9\x01\n" +
"\x13CreateCheckoutOrder\x12\x14\n" +
"\x05terms\x18\x01 \x01(\tR\x05terms\x12\x1a\n" +
"\bcheckout\x18\x02 \x01(\tR\bcheckout\x12\"\n" +
"\fconfirmation\x18\x03 \x01(\tR\fconfirmation\x12\x12\n" +
"\x04push\x18\x04 \x01(\tR\x04push\x12\x1e\n" +
"\n" +
"validation\x18\x05 \x01(\tR\n" +
"validation\x12\x18\n" +
"\acountry\x18\x06 \x01(\tR\acountry\"@\n" +
"\fOrderCreated\x12\x18\n" +
"\aorderId\x18\x01 \x01(\tR\aorderId\x12\x16\n" +
"\x06status\x18\x02 \x01(\tR\x06status\"\x06\n" +
"\x04Noop\"t\n" +
"\x12InitializeCheckout\x12\x18\n" +
"\aorderId\x18\x01 \x01(\tR\aorderId\x12\x16\n" +
"\x06status\x18\x02 \x01(\tR\x06status\x12,\n" +
"\x11paymentInProgress\x18\x03 \x01(\bR\x11paymentInProgressB.Z,git.tornberg.me/go-cart-actor/proto;messagesb\x06proto3"
var file_messages_proto_rawDesc = string([]byte{
0x0a, 0x0e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x12, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0x12, 0x0a, 0x10, 0x43, 0x6c,
0x65, 0x61, 0x72, 0x43, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x97,
0x05, 0x0a, 0x07, 0x41, 0x64, 0x64, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x69, 0x74,
0x65, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x69, 0x74, 0x65,
0x6d, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18,
0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12,
0x14, 0x0a, 0x05, 0x70, 0x72, 0x69, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05,
0x70, 0x72, 0x69, 0x63, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x6f, 0x72, 0x67, 0x50, 0x72, 0x69, 0x63,
0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x6f, 0x72, 0x67, 0x50, 0x72, 0x69, 0x63,
0x65, 0x12, 0x10, 0x0a, 0x03, 0x73, 0x6b, 0x75, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
0x73, 0x6b, 0x75, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28,
0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65,
0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x14, 0x0a,
0x05, 0x73, 0x74, 0x6f, 0x63, 0x6b, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x73, 0x74,
0x6f, 0x63, 0x6b, 0x12, 0x10, 0x0a, 0x03, 0x74, 0x61, 0x78, 0x18, 0x08, 0x20, 0x01, 0x28, 0x05,
0x52, 0x03, 0x74, 0x61, 0x78, 0x12, 0x14, 0x0a, 0x05, 0x62, 0x72, 0x61, 0x6e, 0x64, 0x18, 0x0d,
0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x62, 0x72, 0x61, 0x6e, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x63,
0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63,
0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67,
0x6f, 0x72, 0x79, 0x32, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65,
0x67, 0x6f, 0x72, 0x79, 0x32, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72,
0x79, 0x33, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f,
0x72, 0x79, 0x33, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x34,
0x18, 0x11, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79,
0x34, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x35, 0x18, 0x12,
0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x35, 0x12,
0x1e, 0x0a, 0x0a, 0x64, 0x69, 0x73, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x65, 0x72, 0x18, 0x0a, 0x20,
0x01, 0x28, 0x09, 0x52, 0x0a, 0x64, 0x69, 0x73, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x65, 0x72, 0x12,
0x20, 0x0a, 0x0b, 0x61, 0x72, 0x74, 0x69, 0x63, 0x6c, 0x65, 0x54, 0x79, 0x70, 0x65, 0x18, 0x0b,
0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x72, 0x74, 0x69, 0x63, 0x6c, 0x65, 0x54, 0x79, 0x70,
0x65, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x49, 0x64, 0x18, 0x13, 0x20,
0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1e, 0x0a,
0x0a, 0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x14, 0x20, 0x01, 0x28,
0x09, 0x52, 0x0a, 0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a,
0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x15, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x1b, 0x0a, 0x06, 0x6f, 0x75, 0x74, 0x6c, 0x65,
0x74, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x06, 0x6f, 0x75, 0x74, 0x6c, 0x65,
0x74, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x49, 0x64, 0x18,
0x16, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x49, 0x64,
0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x18,
0x17, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x02, 0x52, 0x08, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49,
0x64, 0x88, 0x01, 0x01, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x6f, 0x75, 0x74, 0x6c, 0x65, 0x74, 0x42,
0x0a, 0x0a, 0x08, 0x5f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x49, 0x64, 0x42, 0x0b, 0x0a, 0x09, 0x5f,
0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x22, 0x1c, 0x0a, 0x0a, 0x52, 0x65, 0x6d, 0x6f,
0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01,
0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x22, 0x3c, 0x0a, 0x0e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65,
0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x64, 0x18, 0x01,
0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e,
0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e,
0x74, 0x69, 0x74, 0x79, 0x22, 0x86, 0x02, 0x0a, 0x0b, 0x53, 0x65, 0x74, 0x44, 0x65, 0x6c, 0x69,
0x76, 0x65, 0x72, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72,
0x12, 0x14, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0d, 0x52,
0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x12, 0x3c, 0x0a, 0x0b, 0x70, 0x69, 0x63, 0x6b, 0x75, 0x70,
0x50, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x65,
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x50, 0x69, 0x63, 0x6b, 0x75, 0x70, 0x50, 0x6f, 0x69,
0x6e, 0x74, 0x48, 0x00, 0x52, 0x0b, 0x70, 0x69, 0x63, 0x6b, 0x75, 0x70, 0x50, 0x6f, 0x69, 0x6e,
0x74, 0x88, 0x01, 0x01, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18,
0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10,
0x0a, 0x03, 0x7a, 0x69, 0x70, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x7a, 0x69, 0x70,
0x12, 0x1d, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28,
0x09, 0x48, 0x01, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x88, 0x01, 0x01, 0x12,
0x17, 0x0a, 0x04, 0x63, 0x69, 0x74, 0x79, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52,
0x04, 0x63, 0x69, 0x74, 0x79, 0x88, 0x01, 0x01, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x70, 0x69, 0x63,
0x6b, 0x75, 0x70, 0x50, 0x6f, 0x69, 0x6e, 0x74, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x61, 0x64, 0x64,
0x72, 0x65, 0x73, 0x73, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x63, 0x69, 0x74, 0x79, 0x22, 0xf9, 0x01,
0x0a, 0x0e, 0x53, 0x65, 0x74, 0x50, 0x69, 0x63, 0x6b, 0x75, 0x70, 0x50, 0x6f, 0x69, 0x6e, 0x74,
0x12, 0x1e, 0x0a, 0x0a, 0x64, 0x65, 0x6c, 0x69, 0x76, 0x65, 0x72, 0x79, 0x49, 0x64, 0x18, 0x01,
0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x64, 0x65, 0x6c, 0x69, 0x76, 0x65, 0x72, 0x79, 0x49, 0x64,
0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64,
0x12, 0x17, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00,
0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x61, 0x64, 0x64,
0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x61, 0x64,
0x64, 0x72, 0x65, 0x73, 0x73, 0x88, 0x01, 0x01, 0x12, 0x17, 0x0a, 0x04, 0x63, 0x69, 0x74, 0x79,
0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52, 0x04, 0x63, 0x69, 0x74, 0x79, 0x88, 0x01,
0x01, 0x12, 0x15, 0x0a, 0x03, 0x7a, 0x69, 0x70, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x48, 0x03,
0x52, 0x03, 0x7a, 0x69, 0x70, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e,
0x74, 0x72, 0x79, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x04, 0x52, 0x07, 0x63, 0x6f, 0x75,
0x6e, 0x74, 0x72, 0x79, 0x88, 0x01, 0x01, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x6e, 0x61, 0x6d, 0x65,
0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x42, 0x07, 0x0a, 0x05,
0x5f, 0x63, 0x69, 0x74, 0x79, 0x42, 0x06, 0x0a, 0x04, 0x5f, 0x7a, 0x69, 0x70, 0x42, 0x0a, 0x0a,
0x08, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x22, 0xd6, 0x01, 0x0a, 0x0b, 0x50, 0x69,
0x63, 0x6b, 0x75, 0x70, 0x50, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18,
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x04, 0x6e, 0x61, 0x6d,
0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x88,
0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20,
0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x88, 0x01,
0x01, 0x12, 0x17, 0x0a, 0x04, 0x63, 0x69, 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48,
0x02, 0x52, 0x04, 0x63, 0x69, 0x74, 0x79, 0x88, 0x01, 0x01, 0x12, 0x15, 0x0a, 0x03, 0x7a, 0x69,
0x70, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x03, 0x52, 0x03, 0x7a, 0x69, 0x70, 0x88, 0x01,
0x01, 0x12, 0x1d, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x06, 0x20, 0x01,
0x28, 0x09, 0x48, 0x04, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x88, 0x01, 0x01,
0x42, 0x07, 0x0a, 0x05, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x61, 0x64,
0x64, 0x72, 0x65, 0x73, 0x73, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x63, 0x69, 0x74, 0x79, 0x42, 0x06,
0x0a, 0x04, 0x5f, 0x7a, 0x69, 0x70, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74,
0x72, 0x79, 0x22, 0x20, 0x0a, 0x0e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x44, 0x65, 0x6c, 0x69,
0x76, 0x65, 0x72, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d,
0x52, 0x02, 0x69, 0x64, 0x22, 0xb9, 0x01, 0x0a, 0x13, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43,
0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x14, 0x0a, 0x05,
0x74, 0x65, 0x72, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x65, 0x72,
0x6d, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x18, 0x02,
0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x12, 0x22,
0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x72, 0x6d, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03,
0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x72, 0x6d, 0x61, 0x74, 0x69,
0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x75, 0x73, 0x68, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09,
0x52, 0x04, 0x70, 0x75, 0x73, 0x68, 0x12, 0x1e, 0x0a, 0x0a, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61,
0x74, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x76, 0x61, 0x6c, 0x69,
0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72,
0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79,
0x22, 0x40, 0x0a, 0x0c, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64,
0x12, 0x18, 0x0a, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
0x09, 0x52, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74,
0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74,
0x75, 0x73, 0x22, 0x06, 0x0a, 0x04, 0x4e, 0x6f, 0x6f, 0x70, 0x22, 0x74, 0x0a, 0x12, 0x49, 0x6e,
0x69, 0x74, 0x69, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74,
0x12, 0x18, 0x0a, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
0x09, 0x52, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74,
0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74,
0x75, 0x73, 0x12, 0x2c, 0x0a, 0x11, 0x70, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x6e, 0x50,
0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x70,
0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x6e, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73,
0x22, 0x79, 0x0a, 0x0b, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x12,
0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74,
0x79, 0x70, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69,
0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69,
0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69,
0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74,
0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20,
0x01, 0x28, 0x09, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x71, 0x0a, 0x0a, 0x41,
0x64, 0x64, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64,
0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x14, 0x0a,
0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x76, 0x61,
0x6c, 0x75, 0x65, 0x12, 0x39, 0x0a, 0x0c, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75,
0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x73, 0x2e, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65,
0x52, 0x0c, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x22, 0x1f,
0x0a, 0x0d, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x12,
0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x42,
0x2e, 0x5a, 0x2c, 0x67, 0x69, 0x74, 0x2e, 0x74, 0x6f, 0x72, 0x6e, 0x62, 0x65, 0x72, 0x67, 0x2e,
0x6d, 0x65, 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72, 0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72,
0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x3b, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x62,
0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
})
var (
file_messages_proto_rawDescOnce sync.Once
@@ -1112,25 +1249,27 @@ func file_messages_proto_rawDescGZIP() []byte {
return file_messages_proto_rawDescData
}
var file_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
var file_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 15)
var file_messages_proto_goTypes = []any{
(*AddRequest)(nil), // 0: messages.AddRequest
(*SetCartRequest)(nil), // 1: messages.SetCartRequest
(*AddItem)(nil), // 2: messages.AddItem
(*RemoveItem)(nil), // 3: messages.RemoveItem
(*ChangeQuantity)(nil), // 4: messages.ChangeQuantity
(*SetDelivery)(nil), // 5: messages.SetDelivery
(*SetPickupPoint)(nil), // 6: messages.SetPickupPoint
(*PickupPoint)(nil), // 7: messages.PickupPoint
(*RemoveDelivery)(nil), // 8: messages.RemoveDelivery
(*CreateCheckoutOrder)(nil), // 9: messages.CreateCheckoutOrder
(*OrderCreated)(nil), // 10: messages.OrderCreated
(*Noop)(nil), // 11: messages.Noop
(*InitializeCheckout)(nil), // 12: messages.InitializeCheckout
(*ClearCartRequest)(nil), // 0: messages.ClearCartRequest
(*AddItem)(nil), // 1: messages.AddItem
(*RemoveItem)(nil), // 2: messages.RemoveItem
(*ChangeQuantity)(nil), // 3: messages.ChangeQuantity
(*SetDelivery)(nil), // 4: messages.SetDelivery
(*SetPickupPoint)(nil), // 5: messages.SetPickupPoint
(*PickupPoint)(nil), // 6: messages.PickupPoint
(*RemoveDelivery)(nil), // 7: messages.RemoveDelivery
(*CreateCheckoutOrder)(nil), // 8: messages.CreateCheckoutOrder
(*OrderCreated)(nil), // 9: messages.OrderCreated
(*Noop)(nil), // 10: messages.Noop
(*InitializeCheckout)(nil), // 11: messages.InitializeCheckout
(*VoucherRule)(nil), // 12: messages.VoucherRule
(*AddVoucher)(nil), // 13: messages.AddVoucher
(*RemoveVoucher)(nil), // 14: messages.RemoveVoucher
}
var file_messages_proto_depIdxs = []int32{
0, // 0: messages.SetCartRequest.items:type_name -> messages.AddRequest
7, // 1: messages.SetDelivery.pickupPoint:type_name -> messages.PickupPoint
6, // 0: messages.SetDelivery.pickupPoint:type_name -> messages.PickupPoint
12, // 1: messages.AddVoucher.voucherRules:type_name -> messages.VoucherRule
2, // [2:2] is the sub-list for method output_type
2, // [2:2] is the sub-list for method input_type
2, // [2:2] is the sub-list for extension type_name
@@ -1143,18 +1282,17 @@ func file_messages_proto_init() {
if File_messages_proto != nil {
return
}
file_messages_proto_msgTypes[0].OneofWrappers = []any{}
file_messages_proto_msgTypes[2].OneofWrappers = []any{}
file_messages_proto_msgTypes[1].OneofWrappers = []any{}
file_messages_proto_msgTypes[4].OneofWrappers = []any{}
file_messages_proto_msgTypes[5].OneofWrappers = []any{}
file_messages_proto_msgTypes[6].OneofWrappers = []any{}
file_messages_proto_msgTypes[7].OneofWrappers = []any{}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_messages_proto_rawDesc), len(file_messages_proto_rawDesc)),
NumEnums: 0,
NumMessages: 13,
NumMessages: 15,
NumExtensions: 0,
NumServices: 0,
},

187
pkg/proxy/remotehost.go Normal file
View File

@@ -0,0 +1,187 @@
package proxy
import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"time"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// RemoteHost mirrors the lightweight controller used for remote node
// interaction.
type RemoteHost struct {
host string
httpBase string
conn *grpc.ClientConn
transport *http.Transport
client *http.Client
controlClient messages.ControlPlaneClient
missedPings int
}
func NewRemoteHost(host string) (*RemoteHost, error) {
target := fmt.Sprintf("%s:1337", host)
conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Printf("AddRemote: dial %s failed: %v", target, err)
return nil, err
}
controlClient := messages.NewControlPlaneClient(conn)
transport := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
DisableKeepAlives: false,
IdleConnTimeout: 120 * time.Second,
}
client := &http.Client{Transport: transport, Timeout: 10 * time.Second}
return &RemoteHost{
host: host,
httpBase: fmt.Sprintf("http://%s:8080/cart", host),
conn: conn,
transport: transport,
client: client,
controlClient: controlClient,
missedPings: 0,
}, nil
}
func (h *RemoteHost) Name() string {
return h.host
}
func (h *RemoteHost) Close() error {
if h.conn != nil {
h.conn.Close()
}
return nil
}
func (h *RemoteHost) Ping() bool {
var err error = errors.ErrUnsupported
for err != nil {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
_, err = h.controlClient.Ping(ctx, &messages.Empty{})
cancel()
if err != nil {
h.missedPings++
log.Printf("Ping %s failed (%d) %v", h.host, h.missedPings, err)
}
if !h.IsHealthy() {
return false
}
time.Sleep(time.Millisecond * 200)
}
h.missedPings = 0
return true
}
func (h *RemoteHost) Negotiate(knownHosts []string) ([]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := h.controlClient.Negotiate(ctx, &messages.NegotiateRequest{
KnownHosts: knownHosts,
})
if err != nil {
h.missedPings++
log.Printf("Negotiate %s failed: %v", h.host, err)
return nil, err
}
h.missedPings = 0
return resp.Hosts, nil
}
func (h *RemoteHost) GetActorIds() []uint64 {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
reply, err := h.controlClient.GetLocalActorIds(ctx, &messages.Empty{})
if err != nil {
log.Printf("Init remote %s: GetCartIds error: %v", h.host, err)
h.missedPings++
return []uint64{}
}
return reply.GetIds()
}
func (h *RemoteHost) AnnounceOwnership(ownerHost string, uids []uint64) {
_, err := h.controlClient.AnnounceOwnership(context.Background(), &messages.OwnershipAnnounce{
Host: ownerHost,
Ids: uids,
})
if err != nil {
log.Printf("ownership announce to %s failed: %v", h.host, err)
h.missedPings++
return
}
h.missedPings = 0
}
func (h *RemoteHost) AnnounceExpiry(uids []uint64) {
_, err := h.controlClient.AnnounceExpiry(context.Background(), &messages.ExpiryAnnounce{
Host: h.host,
Ids: uids,
})
if err != nil {
log.Printf("expiry announce to %s failed: %v", h.host, err)
h.missedPings++
return
}
h.missedPings = 0
}
func (h *RemoteHost) Proxy(id uint64, w http.ResponseWriter, r *http.Request) (bool, error) {
target := fmt.Sprintf("%s%s", h.httpBase, r.URL.RequestURI())
req, err := http.NewRequestWithContext(r.Context(), r.Method, target, r.Body)
if err != nil {
http.Error(w, "proxy build error", http.StatusBadGateway)
return false, err
}
//r.Body = io.NopCloser(bytes.NewReader(bodyCopy))
req.Header.Set("X-Forwarded-Host", r.Host)
for k, v := range r.Header {
for _, vv := range v {
req.Header.Add(k, vv)
}
}
res, err := h.client.Do(req)
if err != nil {
http.Error(w, "proxy request error", http.StatusBadGateway)
return false, err
}
defer res.Body.Close()
for k, v := range res.Header {
for _, vv := range v {
w.Header().Add(k, vv)
}
}
w.Header().Set("X-Cart-Owner-Routed", "true")
if res.StatusCode >= 200 && res.StatusCode <= 299 {
w.WriteHeader(res.StatusCode)
_, copyErr := io.Copy(w, res.Body)
if copyErr != nil {
return true, copyErr
}
return true, nil
}
return false, fmt.Errorf("proxy response status %d", res.StatusCode)
}
func (r *RemoteHost) IsHealthy() bool {
return r.missedPings < 3
}

347
pkg/voucher/parser.go Normal file
View File

@@ -0,0 +1,347 @@
package voucher
import (
"errors"
"fmt"
"strconv"
"strings"
"unicode"
)
/*
Package voucher - rule parser
A lightweight parser for voucher rule expressions.
Supported rule kinds (case-insensitive keywords):
sku=SKU1|SKU2|SKU3
- At least one of the listed SKUs must be present in the cart.
category=CatA|CatB|CatC
- At least one of the listed categories must be present.
min_total>=12345
- Cart total (Inc VAT) must be at least this value (int64).
min_item_price>=5000
- At least one individual item (Inc VAT single unit price) must be at least this value (int64).
Rule list grammar (simplified):
rules := rule (sep rule)*
rule := (sku|category) '=' valueList
| (min_total|min_item_price) comparator number
valueList := value ('|' value)*
comparator := '>=' (only comparator currently supported for numeric rules)
sep := ';' | ',' | newline
Whitespace is ignored around tokens.
Example:
sku=ABC123|XYZ999; category=Shoes|Bags
min_total>=10000
min_item_price>=2500, category=Accessories
Parsing returns a RuleSet which can later be evaluated against a generic context.
The evaluation context uses simple Item abstractions to avoid tight coupling with
the cart implementation (which currently lives under cmd/cart and cannot be
imported due to being in package main).
This is intentionally conservative and extensible:
* Adding new rule kinds: extend RuleKind constants, add parse + evaluate logic.
* Supporting new operators: extend numeric rule parsing & evaluation.
*/
var (
// ErrEmptyExpression is returned when the input string has only whitespace.
ErrEmptyExpression = errors.New("voucher: empty rule expression")
// ErrInvalidRule indicates a syntactic or semantic issue with a single rule fragment.
ErrInvalidRule = errors.New("voucher: invalid rule")
)
// RuleKind enumerates supported rule kinds.
type RuleKind string
const (
RuleSku RuleKind = "sku"
RuleCategory RuleKind = "category"
RuleMinTotal RuleKind = "min_total"
RuleMinItemPrice RuleKind = "min_item_price"
)
// ruleCondition represents a single, parsed rule.
type ruleCondition struct {
Kind RuleKind
StringVals []string // For sku / category multi-value list
MinValue *int64 // For numeric threshold rules
// Operator reserved for future (e.g., >, >=, ==). Currently always ">=" for numeric kinds.
Operator string
}
// RuleSet groups multiple rule conditions (logical AND).
// All conditions must pass for Applies() to return true.
type RuleSet struct {
Conditions []ruleCondition
Source string // original, trimmed source string
}
// Item is a minimal abstraction for evaluation (decoupled from cart domain structs).
type Item struct {
Sku string
Category string
UnitPrice int64 // Inc VAT (single unit)
}
// EvalContext bundles cart-like data necessary for evaluation.
type EvalContext struct {
Items []Item
CartTotalInc int64
}
// Applies returns true if all rule conditions pass for the context.
func (rs *RuleSet) Applies(ctx EvalContext) bool {
for _, c := range rs.Conditions {
switch c.Kind {
case RuleSku:
if !anyItem(ctx.Items, func(it Item) bool {
return containsFold(c.StringVals, it.Sku)
}) {
return false
}
case RuleCategory:
if !anyItem(ctx.Items, func(it Item) bool {
return containsFold(c.StringVals, it.Category)
}) {
return false
}
case RuleMinTotal:
if c.MinValue == nil || ctx.CartTotalInc < *c.MinValue {
return false
}
case RuleMinItemPrice:
if c.MinValue == nil {
return false
}
if !anyItem(ctx.Items, func(it Item) bool {
return it.UnitPrice >= *c.MinValue
}) {
return false
}
default:
// Unknown kinds fail closed to avoid granting unintended discounts.
return false
}
}
return true
}
// anyItem returns true if predicate matches any item.
func anyItem(items []Item, pred func(Item) bool) bool {
for _, it := range items {
if pred(it) {
return true
}
}
return false
}
// ParseRules parses a rule expression into a RuleSet.
func ParseRules(input string) (*RuleSet, error) {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return nil, ErrEmptyExpression
}
fragments := splitRuleFragments(trimmed)
if len(fragments) == 0 {
return nil, ErrInvalidRule
}
var conditions []ruleCondition
for _, frag := range fragments {
if frag == "" {
continue
}
c, err := parseFragment(frag)
if err != nil {
return nil, fmt.Errorf("%w: %s (%v)", ErrInvalidRule, frag, err)
}
conditions = append(conditions, c)
}
if len(conditions) == 0 {
return nil, ErrInvalidRule
}
return &RuleSet{
Conditions: conditions,
Source: trimmed,
}, nil
}
// splitRuleFragments splits on ; , or newline, while respecting basic structure.
func splitRuleFragments(s string) []string {
// Normalize line endings
s = strings.ReplaceAll(s, "\r\n", "\n")
// We allow separators: newline, semicolon, comma.
seps := func(r rune) bool {
return r == ';' || r == '\n' || r == ','
}
raw := strings.FieldsFunc(s, seps)
out := make([]string, 0, len(raw))
for _, f := range raw {
t := strings.TrimSpace(f)
if t != "" {
out = append(out, t)
}
}
return out
}
// parseFragment parses an individual rule fragment.
func parseFragment(frag string) (ruleCondition, error) {
lower := strings.ToLower(frag)
// Numeric rules have form: <kind> >= number
if strings.HasPrefix(lower, string(RuleMinTotal)) ||
strings.HasPrefix(lower, string(RuleMinItemPrice)) {
return parseNumericRule(frag)
}
// Key=Value list rules (sku / category).
if i := strings.Index(frag, "="); i > 0 {
key := strings.TrimSpace(frag[:i])
valPart := strings.TrimSpace(frag[i+1:])
if key == "" || valPart == "" {
return ruleCondition{}, errors.New("empty key/value")
}
kind := RuleKind(strings.ToLower(key))
switch kind {
case RuleSku, RuleCategory:
values := splitAndClean(valPart, "|")
if len(values) == 0 {
return ruleCondition{}, errors.New("empty value list")
}
return ruleCondition{
Kind: kind,
StringVals: values,
}, nil
default:
return ruleCondition{}, fmt.Errorf("unsupported key '%s'", key)
}
}
return ruleCondition{}, fmt.Errorf("unrecognized fragment '%s'", frag)
}
func parseNumericRule(frag string) (ruleCondition, error) {
// Support only '>=' for now.
var kind RuleKind
var rest string
fragTrim := strings.TrimSpace(frag)
switch {
case strings.HasPrefix(strings.ToLower(fragTrim), string(RuleMinTotal)):
kind = RuleMinTotal
rest = strings.TrimSpace(fragTrim[len(RuleMinTotal):])
case strings.HasPrefix(strings.ToLower(fragTrim), string(RuleMinItemPrice)):
kind = RuleMinItemPrice
rest = strings.TrimSpace(fragTrim[len(RuleMinItemPrice):])
default:
return ruleCondition{}, fmt.Errorf("unknown numeric rule '%s'", frag)
}
// Expect operator and number (>= <number>)
rest = stripLeadingSpace(rest)
if !strings.HasPrefix(rest, ">=") {
return ruleCondition{}, fmt.Errorf("expected '>=' in '%s'", frag)
}
numStr := strings.TrimSpace(rest[2:])
if numStr == "" {
return ruleCondition{}, fmt.Errorf("missing numeric value in '%s'", frag)
}
value, err := strconv.ParseInt(numStr, 10, 64)
if err != nil {
return ruleCondition{}, fmt.Errorf("invalid number '%s': %v", numStr, err)
}
if value < 0 {
return ruleCondition{}, fmt.Errorf("negative threshold %d", value)
}
return ruleCondition{
Kind: kind,
MinValue: &value,
Operator: ">=",
}, nil
}
func stripLeadingSpace(s string) string {
for len(s) > 0 && unicode.IsSpace(rune(s[0])) {
s = s[1:]
}
return s
}
func splitAndClean(s string, sep string) []string {
raw := strings.Split(s, sep)
out := make([]string, 0, len(raw))
for _, r := range raw {
t := strings.TrimSpace(r)
if t != "" {
out = append(out, t)
}
}
return out
}
func containsFold(list []string, candidate string) bool {
for _, v := range list {
if strings.EqualFold(v, candidate) {
return true
}
}
return false
}
// Describe returns a human-friendly summary of the parsed rule set.
func (rs *RuleSet) Describe() string {
if rs == nil {
return "<nil>"
}
var parts []string
for _, c := range rs.Conditions {
switch c.Kind {
case RuleSku, RuleCategory:
parts = append(parts, fmt.Sprintf("%s in (%s)", c.Kind, strings.Join(c.StringVals, "|")))
case RuleMinTotal, RuleMinItemPrice:
if c.MinValue != nil {
parts = append(parts, fmt.Sprintf("%s %s %d", c.Kind, c.OperatorOr(">="), *c.MinValue))
}
default:
parts = append(parts, fmt.Sprintf("unknown(%s)", c.Kind))
}
}
return strings.Join(parts, " AND ")
}
func (c ruleCondition) OperatorOr(def string) string {
if c.Operator == "" {
return def
}
return c.Operator
}
// --- Convenience helpers for incremental adoption ---
// MustParseRules panics on parse error (useful in tests or static initialization).
func MustParseRules(expr string) *RuleSet {
rs, err := ParseRules(expr)
if err != nil {
panic(err)
}
return rs
}

179
pkg/voucher/parser_test.go Normal file
View File

@@ -0,0 +1,179 @@
package voucher
import (
"errors"
"testing"
)
func TestParseRules_SimpleSku(t *testing.T) {
rs, err := ParseRules("sku=ABC123|XYZ999|def456")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rs.Conditions) != 1 {
t.Fatalf("expected 1 condition got %d", len(rs.Conditions))
}
c := rs.Conditions[0]
if c.Kind != RuleSku {
t.Fatalf("expected kind sku got %s", c.Kind)
}
if len(c.StringVals) != 3 {
t.Fatalf("expected 3 sku values got %d", len(c.StringVals))
}
want := []string{"ABC123", "XYZ999", "def456"}
for i, v := range want {
if c.StringVals[i] != v {
t.Fatalf("expected sku[%d]=%s got %s", i, v, c.StringVals[i])
}
}
}
func TestParseRules_CategoryAndSkuMixedSeparators(t *testing.T) {
rs, err := ParseRules(" category=Shoes|Bags ; sku= A | B , min_total>=1000\nmin_item_price>=500")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rs.Conditions) != 4 {
t.Fatalf("expected 4 conditions got %d", len(rs.Conditions))
}
kinds := []RuleKind{RuleCategory, RuleSku, RuleMinTotal, RuleMinItemPrice}
for i, k := range kinds {
if rs.Conditions[i].Kind != k {
t.Fatalf("expected condition[%d] kind %s got %s", i, k, rs.Conditions[i].Kind)
}
}
// Validate numeric thresholds
if rs.Conditions[2].MinValue == nil || *rs.Conditions[2].MinValue != 1000 {
t.Fatalf("expected min_total>=1000 got %+v", rs.Conditions[2])
}
if rs.Conditions[3].MinValue == nil || *rs.Conditions[3].MinValue != 500 {
t.Fatalf("expected min_item_price>=500 got %+v", rs.Conditions[3])
}
}
func TestParseRules_Empty(t *testing.T) {
_, err := ParseRules(" \n ")
if !errors.Is(err, ErrEmptyExpression) {
t.Fatalf("expected ErrEmptyExpression got %v", err)
}
}
func TestParseRules_Invalid(t *testing.T) {
_, err := ParseRules("unknown=foo")
if err == nil {
t.Fatal("expected error for unknown key")
}
_, err = ParseRules("min_total>100") // wrong operator
if err == nil {
t.Fatal("expected error for wrong operator")
}
_, err = ParseRules("min_total>=") // missing value
if err == nil {
t.Fatal("expected error for missing numeric value")
}
}
func TestRuleSet_Applies(t *testing.T) {
rs := MustParseRules("sku=ABC123|XYZ999; category=Shoes|min_total>=10000; min_item_price>=3000")
ctx := EvalContext{
Items: []Item{
{Sku: "ABC123", Category: "Shoes", UnitPrice: 2500},
{Sku: "FFF000", Category: "Accessories", UnitPrice: 3200},
},
CartTotalInc: 12000,
}
if !rs.Applies(ctx) {
t.Fatalf("expected rules to apply")
}
// Fail due to missing sku/category
ctx2 := EvalContext{
Items: []Item{
{Sku: "NOPE", Category: "Different", UnitPrice: 4000},
},
CartTotalInc: 20000,
}
if rs.Applies(ctx2) {
t.Fatalf("expected rules NOT to apply (sku/category mismatch)")
}
// Fail due to min_total
ctx3 := EvalContext{
Items: []Item{
{Sku: "ABC123", Category: "Shoes", UnitPrice: 2500},
{Sku: "FFF000", Category: "Accessories", UnitPrice: 3200},
},
CartTotalInc: 9000,
}
if rs.Applies(ctx3) {
t.Fatalf("expected rules NOT to apply (min_total not reached)")
}
// Fail due to min_item_price (no item >=3000)
ctx4 := EvalContext{
Items: []Item{
{Sku: "ABC123", Category: "Shoes", UnitPrice: 2500},
{Sku: "FFF000", Category: "Accessories", UnitPrice: 2800},
},
CartTotalInc: 15000,
}
if rs.Applies(ctx4) {
t.Fatalf("expected rules NOT to apply (min_item_price not satisfied)")
}
}
func TestRuleSet_Applies_CaseInsensitive(t *testing.T) {
rs := MustParseRules("SKU=abc123|xyz999; CATEGORY=Shoes")
ctx := EvalContext{
Items: []Item{
{Sku: "AbC123", Category: "shoes", UnitPrice: 1000},
},
CartTotalInc: 1000,
}
if !rs.Applies(ctx) {
t.Fatalf("expected rules to apply (case-insensitive match)")
}
}
func TestDescribe(t *testing.T) {
rs := MustParseRules("sku=A|B|min_total>=500")
desc := rs.Describe()
// Loose assertions to avoid over-specification
if desc == "" {
t.Fatalf("expected non-empty description")
}
if !(contains(desc, "sku") && contains(desc, "min_total")) {
t.Fatalf("description missing expected parts: %s", desc)
}
}
func contains(haystack, needle string) bool {
return len(haystack) >= len(needle) && indexOf(haystack, needle) >= 0
}
// Simple substring search (avoid importing strings to show intent explicitly here)
func indexOf(s, sub string) int {
outer:
for i := 0; i+len(sub) <= len(s); i++ {
for j := 0; j < len(sub); j++ {
if s[i+j] != sub[j] {
continue outer
}
}
return i
}
return -1
}
func TestMustParseRules_Panics(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatalf("expected panic for invalid expression")
}
}()
MustParseRules("~~ totally invalid ~~")
}

39
pkg/voucher/service.go Normal file
View File

@@ -0,0 +1,39 @@
package voucher
import (
"errors"
"git.tornberg.me/go-cart-actor/pkg/messages"
)
type Rule struct {
Type string `json:"type"`
Value int64 `json:"value"`
}
type Voucher struct {
Code string `json:"code"`
Value int64 `json:"discount"`
TaxValue int64 `json:"taxValue"`
TaxRate int `json:"taxRate"`
rules []Rule `json:"rules"`
}
type Service struct {
// Add fields here
}
var ErrInvalidCode = errors.New("invalid vouchercode")
func (s *Service) GetVoucher(code string) (*messages.AddVoucher, error) {
if code == "" {
return nil, ErrInvalidCode
}
value := int64(250_00)
return &messages.AddVoucher{
Code: code,
Value: value,
VoucherRules: make([]*messages.VoucherRule, 0),
}, nil
}

View File

@@ -1,397 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"time"
messages "git.tornberg.me/go-cart-actor/proto"
)
type PoolServer struct {
pod_name string
pool GrainPool
}
func NewPoolServer(pool GrainPool, pod_name string) *PoolServer {
return &PoolServer{
pod_name: pod_name,
pool: pool,
}
}
func (s *PoolServer) process(id CartId, mutation interface{}) (*CartGrain, error) {
grain, err := s.pool.Apply(id, mutation)
if err != nil {
return nil, err
}
return grain, nil
}
func (s *PoolServer) HandleGet(w http.ResponseWriter, r *http.Request, id CartId) error {
grain, err := s.pool.Get(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.process(id, &messages.AddRequest{Sku: sku, Quantity: 1})
if err != nil {
return err
}
return s.WriteResult(w, data)
}
func ErrorHandler(fn func(w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
err := fn(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) 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.process(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.process(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.process(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.process(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.process(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.process(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.process(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 (s *PoolServer) CreateOrUpdateCheckout(host string, id CartId) (*CheckoutOrder, error) {
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: getCountryFromHost(host),
}
// Get current grain state (may be local or remote)
grain, err := s.pool.Get(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 KlarnaInstance.UpdateOrder(grain.OrderReference, bytes.NewReader(payload))
} else {
return KlarnaInstance.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(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) error {
return func(w http.ResponseWriter, r *http.Request) error {
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
}
}
return fn(id, w, r)
}
}
// 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) error {
return func(w http.ResponseWriter, r *http.Request) error {
raw := r.PathValue("id")
// If no id supplied, generate a new one
if raw == "" {
id := MustNewCartId()
w.Header().Set("Set-Cart-Id", id.String())
return fn(id, w, r)
}
// Parse base62 cart id
id, ok := ParseCartId(raw)
if !ok {
return fmt.Errorf("invalid cart id format")
}
return fn(id, w, r)
}
}
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(cartId); ok {
handled, err := ownerHost.Proxy(cartId, w, r)
if err != nil {
log.Printf("proxy failed: %v, taking ownership", err)
s.pool.TakeOwnership(cartId)
} else if handled {
return nil
}
}
// Local ownership or no owner known, proceed with local handling
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 /", ErrorHandler(CookieCartIdHandler(s.ProxyHandler(s.HandleGet))))
mux.HandleFunc("GET /add/{sku}", ErrorHandler(CookieCartIdHandler(s.ProxyHandler(s.HandleAddSku))))
mux.HandleFunc("POST /", ErrorHandler(CookieCartIdHandler(s.ProxyHandler(s.HandleAddRequest))))
mux.HandleFunc("POST /set", ErrorHandler(CookieCartIdHandler(s.ProxyHandler(s.HandleSetCartItems))))
mux.HandleFunc("DELETE /{itemId}", ErrorHandler(CookieCartIdHandler(s.ProxyHandler(s.HandleDeleteItem))))
mux.HandleFunc("PUT /", ErrorHandler(CookieCartIdHandler(s.ProxyHandler(s.HandleQuantityChange))))
mux.HandleFunc("DELETE /", ErrorHandler(CookieCartIdHandler(s.ProxyHandler(s.RemoveCartCookie))))
mux.HandleFunc("POST /delivery", ErrorHandler(CookieCartIdHandler(s.ProxyHandler(s.HandleSetDelivery))))
mux.HandleFunc("DELETE /delivery/{deliveryId}", ErrorHandler(CookieCartIdHandler(s.ProxyHandler(s.HandleRemoveDelivery))))
mux.HandleFunc("PUT /delivery/{deliveryId}/pickupPoint", ErrorHandler(CookieCartIdHandler(s.ProxyHandler(s.HandleSetPickupPoint))))
mux.HandleFunc("GET /checkout", ErrorHandler(CookieCartIdHandler(s.ProxyHandler(s.HandleCheckout))))
mux.HandleFunc("GET /confirmation/{orderId}", ErrorHandler(CookieCartIdHandler(s.ProxyHandler(s.HandleConfirmation))))
mux.HandleFunc("GET /byid/{id}", ErrorHandler(CartIdHandler(s.ProxyHandler(s.HandleGet))))
mux.HandleFunc("GET /byid/{id}/add/{sku}", ErrorHandler(CartIdHandler(s.ProxyHandler(s.HandleAddSku))))
mux.HandleFunc("POST /byid/{id}", ErrorHandler(CartIdHandler(s.ProxyHandler(s.HandleAddRequest))))
mux.HandleFunc("DELETE /byid/{id}/{itemId}", ErrorHandler(CartIdHandler(s.ProxyHandler(s.HandleDeleteItem))))
mux.HandleFunc("PUT /byid/{id}", ErrorHandler(CartIdHandler(s.ProxyHandler(s.HandleQuantityChange))))
mux.HandleFunc("POST /byid/{id}/delivery", ErrorHandler(CartIdHandler(s.ProxyHandler(s.HandleSetDelivery))))
mux.HandleFunc("DELETE /byid/{id}/delivery/{deliveryId}", ErrorHandler(CartIdHandler(s.ProxyHandler(s.HandleRemoveDelivery))))
mux.HandleFunc("PUT /byid/{id}/delivery/{deliveryId}/pickupPoint", ErrorHandler(CartIdHandler(s.ProxyHandler(s.HandleSetPickupPoint))))
mux.HandleFunc("GET /byid/{id}/checkout", ErrorHandler(CartIdHandler(s.ProxyHandler(s.HandleCheckout))))
mux.HandleFunc("GET /byid/{id}/confirmation", ErrorHandler(CartIdHandler(s.ProxyHandler(s.HandleConfirmation))))
return mux
}

View File

@@ -1,35 +0,0 @@
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
}

View File

@@ -12,7 +12,7 @@ option go_package = "git.tornberg.me/go-cart-actor/proto;messages";
// - Liveness (Ping)
// - Membership negotiation (Negotiate)
// - Deterministic ring-based ownership (ConfirmOwner RPC removed)
// - Cart ID listing for remote grain spawning (GetCartIds)
// - Actor ID listing for remote grain spawning (GetActorIds)
// - Graceful shutdown notifications (Closing)
// No authentication / TLS is defined initially (can be added later).
// -----------------------------------------------------------------------------
@@ -37,8 +37,8 @@ message NegotiateReply {
}
// CartIdsReply returns the list of cart IDs (string form) currently owned locally.
message CartIdsReply {
repeated uint64 cart_ids = 1;
message ActorIdsReply {
repeated uint64 ids = 1;
}
// OwnerChangeAck retained as response type for Closing RPC (ConfirmOwner removed).
@@ -56,13 +56,13 @@ message ClosingNotice {
// First claim wins; receivers SHOULD NOT overwrite an existing different owner.
message OwnershipAnnounce {
string host = 1; // announcing host
repeated uint64 cart_ids = 2; // newly claimed cart ids
repeated uint64 ids = 2; // newly claimed cart ids
}
// ExpiryAnnounce broadcasts that a host evicted the provided cart IDs.
message ExpiryAnnounce {
string host = 1;
repeated uint64 cart_ids = 2;
repeated uint64 ids = 2;
}
// ControlPlane defines cluster coordination and ownership operations.
@@ -74,7 +74,7 @@ service ControlPlane {
rpc Negotiate(NegotiateRequest) returns (NegotiateReply);
// GetCartIds lists currently owned cart IDs on this node.
rpc GetCartIds(Empty) returns (CartIdsReply);
rpc GetLocalActorIds(Empty) returns (ActorIdsReply);
// ConfirmOwner RPC removed (was legacy ownership acknowledgement; ring-based ownership now authoritative)

View File

@@ -2,19 +2,12 @@ syntax = "proto3";
package messages;
option go_package = "git.tornberg.me/go-cart-actor/proto;messages";
message AddRequest {
int32 quantity = 1;
string sku = 2;
string country = 3;
optional string storeId = 4;
}
message ClearCartRequest {
message SetCartRequest {
repeated AddRequest items = 1;
}
message AddItem {
int64 item_id = 1;
uint32 item_id = 1;
int32 quantity = 2;
int64 price = 3;
int64 orgPrice = 9;
@@ -36,20 +29,21 @@ message AddItem {
string country = 21;
optional string outlet = 12;
optional string storeId = 22;
optional uint32 parentId = 23;
}
message RemoveItem {
int64 Id = 1;
uint32 Id = 1;
}
message ChangeQuantity {
int64 id = 1;
uint32 Id = 1;
int32 quantity = 2;
}
message SetDelivery {
string provider = 1;
repeated int64 items = 2;
repeated uint32 items = 2;
optional PickupPoint pickupPoint = 3;
string country = 4;
string zip = 5;
@@ -58,7 +52,7 @@ message SetDelivery {
}
message SetPickupPoint {
int64 deliveryId = 1;
uint32 deliveryId = 1;
string id = 2;
optional string name = 3;
optional string address = 4;
@@ -77,7 +71,7 @@ message PickupPoint {
}
message RemoveDelivery {
int64 id = 1;
uint32 id = 1;
}
message CreateCheckoutOrder {
@@ -103,3 +97,20 @@ message InitializeCheckout {
string status = 2;
bool paymentInProgress = 3;
}
message VoucherRule {
string type = 2;
string description = 3;
string condition = 4;
string action = 5;
}
message AddVoucher {
string code = 1;
int64 value = 2;
repeated VoucherRule voucherRules = 3;
}
message RemoveVoucher {
uint32 id = 1;
}