Compare commits
136 Commits
refactor/h
...
67f63244bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67f63244bb | ||
|
|
270539a2fe | ||
|
|
dd5de0b1a1 | ||
|
|
3e78dd8b00 | ||
|
|
13dcb1ec45 | ||
|
|
4fa78e786f | ||
|
|
c917bc62fb | ||
|
|
ac8b870eb5 | ||
|
|
cdece4e0d2 | ||
|
|
c9d9d62bfb | ||
|
|
e662192b56 | ||
|
|
a7aab5161b | ||
|
|
7fd6b22c6b | ||
|
|
ba0e820956 | ||
|
|
7a7c577374 | ||
|
|
da2b68b40e | ||
|
|
5816b6cd3b | ||
|
|
b135156837 | ||
|
|
c4c116fc43 | ||
|
|
0994c4f484 | ||
|
|
78436f39ae | ||
|
|
8905c5809c | ||
|
|
4eb621bae4 | ||
|
|
dd8e23aadd | ||
|
|
ea247e2600 | ||
| 718225164f | |||
| cdfd263318 | |||
| e25ca3304e | |||
| af5d4cd325 | |||
| cebd3ea80f | |||
| a0f5ae0c61 | |||
| 67c9b2a7df | |||
| e703b71d35 | |||
| 5bd0bdb44c | |||
|
|
0c9780f36c | ||
|
|
e0d8afae92 | ||
|
|
6d9f9bec11 | ||
|
|
a54435ebbb | ||
|
|
529e70fc68 | ||
|
|
3f9c790dc2 | ||
|
|
5223fef2fa | ||
|
|
7161c2a8b6 | ||
|
|
cc6d48c879 | ||
|
|
756a43b342 | ||
|
|
81246fe497 | ||
|
|
3fa0009b95 | ||
|
|
19fc5a9553 | ||
|
|
ab5d9cb2b7 | ||
|
|
5d4d917f6a | ||
|
|
af7ce20557 | ||
|
|
ed9a02227e | ||
|
|
34e0445857 | ||
| 61457bce6b | |||
| 8bf29020dd | |||
| 44d7c1faad | |||
|
|
caab742461 | ||
|
|
b272282b1f | ||
|
|
43fcf69139 | ||
|
|
7d9fd0ebb4 | ||
|
|
e662c7dafa | ||
|
|
d969da428f | ||
|
|
d0325e302e | ||
|
|
1aa12ff8d9 | ||
|
|
68eca49cd0 | ||
|
|
bb80c9ab13 | ||
|
|
dce00fb5e3 | ||
|
|
81e2fb5faa | ||
|
|
01d8d86c7c | ||
|
|
0c4d9e2245 | ||
|
|
8aab59a5f4 | ||
|
|
c1599af40b | ||
|
|
acf2a3a8c1 | ||
|
|
0a86bd380a | ||
|
|
42913a079f | ||
|
|
c71b668a87 | ||
|
|
89ee3e725f | ||
|
|
aef90e2bbb | ||
|
|
834bf9f7bc | ||
|
|
de77a3b707 | ||
|
|
162a2638fa | ||
|
|
5533d241e9 | ||
|
|
b36125d664 | ||
|
|
cd0ee22ddc | ||
|
|
00fcacf1be | ||
|
|
2378635790 | ||
|
|
da28e993cd | ||
|
|
00c2ff70da | ||
|
|
eb4061f1b8 | ||
|
|
e155736313 | ||
|
|
5e7591335c | ||
|
|
c68f726a96 | ||
|
|
82d564b136 | ||
|
|
eb1f7750df | ||
|
|
ce0bac477a | ||
|
|
7c0e3e84a2 | ||
|
|
c67ebd7a5f | ||
|
|
42e38504a3 | ||
| b7ae36e53c | |||
|
|
d9fb49ec0b | ||
|
|
2202c149b8 | ||
|
|
e91433eda7 | ||
|
|
7eb000fd17 | ||
|
|
9ecd91c163 | ||
|
|
246a5ebd85 | ||
|
|
e1de5a00a0 | ||
|
|
99c9f611e7 | ||
|
|
df0cd58dcd | ||
| 86f97f2888 | |||
| af3eb0d7bf | |||
|
|
a0c82dc351 | ||
|
|
0c127e9d38 | ||
|
|
662b381a34 | ||
|
|
e127251a60 | ||
|
|
b7f0990269 | ||
|
|
d58409e3fc | ||
|
|
2ce45656d9 | ||
|
|
dc352e3b74 | ||
|
|
915d845014 | ||
|
|
4a54661f24 | ||
|
|
918aa7d265 | ||
|
|
a1833d6685 | ||
|
|
614be25ae8 | ||
|
|
71fc23bf50 | ||
| db918730d5 | |||
| 8ecad3060f | |||
|
|
c060680768 | ||
|
|
15089862d5 | ||
|
|
cdb0241c8a | ||
|
|
07a7ec5781 | ||
|
|
fa89670553 | ||
|
|
9ab0c08e79 | ||
| 8682daf481 | |||
|
|
8c2bcf5e75 | ||
|
|
47adb12112 | ||
|
|
4835041f14 | ||
| 104f9fbb4c |
@@ -3,75 +3,41 @@ run-name: ${{ gitea.actor }} build 🚀
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
Metadata:
|
||||
runs-on: arm64
|
||||
outputs:
|
||||
version: ${{ steps.meta.outputs.version }}
|
||||
git_commit: ${{ steps.meta.outputs.git_commit }}
|
||||
build_date: ${{ steps.meta.outputs.build_date }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- id: meta
|
||||
name: Derive build metadata
|
||||
run: |
|
||||
GIT_COMMIT=$(git rev-parse HEAD)
|
||||
if git describe --tags --exact-match >/dev/null 2>&1; then
|
||||
VERSION=$(git describe --tags --exact-match)
|
||||
else
|
||||
VERSION=$(git rev-parse --short=12 HEAD)
|
||||
fi
|
||||
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
echo "git_commit=$GIT_COMMIT" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "build_date=$BUILD_DATE" >> $GITHUB_OUTPUT
|
||||
|
||||
BuildAndDeployAmd64:
|
||||
needs: Metadata
|
||||
runs-on: amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Build amd64 image
|
||||
run: |
|
||||
docker build \
|
||||
--build-arg VERSION=${{ needs.Metadata.outputs.version }} \
|
||||
--build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \
|
||||
--build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \
|
||||
--progress=plain \
|
||||
-t registry.knatofs.se/go-cart-actor-amd64:latest \
|
||||
-t registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }} \
|
||||
-t registry.k6n.net/go-cart-actor-amd64:latest \
|
||||
.
|
||||
- name: Push amd64 images
|
||||
run: |
|
||||
docker push registry.knatofs.se/go-cart-actor-amd64:latest
|
||||
docker push registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }}
|
||||
docker push registry.k6n.net/go-cart-actor-amd64:latest
|
||||
- name: Apply deployment manifests
|
||||
run: kubectl apply -f deployment/deployment.yaml -n cart
|
||||
- name: Rollout amd64 deployment (pin to version)
|
||||
- name: Rollout amd64 backoffice deployment
|
||||
run: |
|
||||
kubectl set image deployment/cart-actor-x86 -n cart cart-actor-amd64=registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }}
|
||||
kubectl rollout status deployment/cart-actor-x86 -n cart
|
||||
kubectl rollout restart deployment/cart-backoffice-x86 -n cart
|
||||
kubectl rollout restart deployment/cart-actor-x86 -n cart
|
||||
|
||||
BuildAndDeployArm64:
|
||||
needs: Metadata
|
||||
runs-on: arm64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build arm64 image
|
||||
run: |
|
||||
docker build \
|
||||
--build-arg VERSION=${{ needs.Metadata.outputs.version }} \
|
||||
--build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \
|
||||
--build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \
|
||||
--progress=plain \
|
||||
-t registry.knatofs.se/go-cart-actor:latest \
|
||||
-t registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }} \
|
||||
-t registry.k6n.net/go-cart-actor:latest \
|
||||
.
|
||||
- name: Push arm64 images
|
||||
run: |
|
||||
docker push registry.knatofs.se/go-cart-actor:latest
|
||||
docker push registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }}
|
||||
docker push registry.k6n.net/go-cart-actor:latest
|
||||
- name: Rollout arm64 deployment (pin to version)
|
||||
run: |
|
||||
kubectl set image deployment/cart-actor-arm64 -n cart cart-actor-arm64=registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }}
|
||||
kubectl rollout status deployment/cart-actor-arm64 -n cart
|
||||
# kubectl set image deployment/cart-actor-arm64 -n cart cart-actor-arm64=registry.k6n.net/go-cart-actor:${{ needs.Metadata.outputs.version }}
|
||||
# # kubectl rollout status deployment/cart-actor-arm64 -n cart
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@ __debug*
|
||||
go-cart-actor
|
||||
data/*.prot
|
||||
data/*.go*
|
||||
data/se/*
|
||||
16
Dockerfile
16
Dockerfile
@@ -59,6 +59,20 @@ RUN --mount=type=cache,target=/go/build-cache \
|
||||
-X main.BuildDate=${BUILD_DATE}" \
|
||||
-o /out/go-cart-actor ./cmd/cart
|
||||
|
||||
RUN --mount=type=cache,target=/go/build-cache \
|
||||
go build -trimpath -ldflags="-s -w \
|
||||
-X main.Version=${VERSION} \
|
||||
-X main.GitCommit=${GIT_COMMIT} \
|
||||
-X main.BuildDate=${BUILD_DATE}" \
|
||||
-o /out/go-cart-backoffice ./cmd/backoffice
|
||||
|
||||
RUN --mount=type=cache,target=/go/build-cache \
|
||||
go build -trimpath -ldflags="-s -w \
|
||||
-X main.Version=${VERSION} \
|
||||
-X main.GitCommit=${GIT_COMMIT} \
|
||||
-X main.BuildDate=${BUILD_DATE}" \
|
||||
-o /out/go-cart-inventory ./cmd/inventory
|
||||
|
||||
############################
|
||||
# Runtime Stage
|
||||
############################
|
||||
@@ -67,6 +81,8 @@ FROM gcr.io/distroless/static-debian12:nonroot AS runtime
|
||||
WORKDIR /
|
||||
|
||||
COPY --from=build /out/go-cart-actor /go-cart-actor
|
||||
COPY --from=build /out/go-cart-backoffice /go-cart-backoffice
|
||||
COPY --from=build /out/go-cart-inventory /go-cart-inventory
|
||||
|
||||
# Document (not expose forcibly) typical ports: 8080 (HTTP), 1337 (gRPC)
|
||||
EXPOSE 8080 1337
|
||||
|
||||
31
README.md
31
README.md
@@ -1,36 +1,5 @@
|
||||
# Go Cart Actor
|
||||
|
||||
## Migration Notes (Ring-based Ownership Transition)
|
||||
|
||||
This release removes the legacy ConfirmOwner ownership negotiation RPC in favor of deterministic ownership via the consistent hashing ring.
|
||||
|
||||
Summary of changes:
|
||||
- ConfirmOwner RPC removed from the ControlPlane service.
|
||||
- OwnerChangeRequest message removed (was only used by ConfirmOwner).
|
||||
- OwnerChangeAck retained solely as the response type for the Closing RPC.
|
||||
- SyncedPool now relies exclusively on the ring for ownership (no quorum negotiation).
|
||||
- Remote proxy creation includes a bounded readiness retry to reduce first-call failures.
|
||||
- New Prometheus ring metrics:
|
||||
- cart_ring_epoch
|
||||
- cart_ring_hosts
|
||||
- cart_ring_vnodes
|
||||
- cart_ring_host_share{host}
|
||||
- cart_ring_lookup_local_total
|
||||
- cart_ring_lookup_remote_total
|
||||
|
||||
Action required for consumers:
|
||||
1. Regenerate protobuf code after pulling (requires protoc-gen-go and protoc-gen-go-grpc installed).
|
||||
2. Remove any client code or automation invoking ConfirmOwner (calls will now return UNIMPLEMENTED if using stale generated stubs).
|
||||
3. Update monitoring/alerts that referenced ConfirmOwner or ownership quorum failures—use ring metrics instead.
|
||||
4. If you previously interpreted “ownership flapping” via ConfirmOwner logs, now check for:
|
||||
- Rapid changes in ring epoch (cart_ring_epoch)
|
||||
- Host churn (cart_ring_hosts)
|
||||
- Imbalance in vnode distribution (cart_ring_host_share)
|
||||
|
||||
No data migration is necessary; cart IDs and grain state are unaffected.
|
||||
|
||||
---
|
||||
|
||||
A distributed cart management system using the actor model pattern.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
280
cmd/backoffice/fileserver.go
Normal file
280
cmd/backoffice/fileserver.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||
)
|
||||
|
||||
type FileServer struct {
|
||||
// Define fields here
|
||||
dataDir string
|
||||
storage actor.LogStorage[cart.CartGrain]
|
||||
}
|
||||
|
||||
func NewFileServer(dataDir string, storage actor.LogStorage[cart.CartGrain]) *FileServer {
|
||||
return &FileServer{
|
||||
dataDir: dataDir,
|
||||
storage: storage,
|
||||
}
|
||||
}
|
||||
|
||||
func isValidId(id string) (uint64, bool) {
|
||||
if nr, err := strconv.ParseUint(id, 10, 64); err == nil {
|
||||
return nr, true
|
||||
}
|
||||
if nr, ok := cart.ParseCartId(id); ok {
|
||||
return uint64(nr), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func isValidFileId(name string) (uint64, bool) {
|
||||
|
||||
parts := strings.Split(name, ".")
|
||||
if len(parts) > 1 && parts[1] == "events" {
|
||||
idStr := parts[0]
|
||||
|
||||
return isValidId(idStr)
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// func AccessTime(info os.FileInfo) (time.Time, bool) {
|
||||
// switch stat := info.Sys().(type) {
|
||||
// case *syscall.Stat_t:
|
||||
// // Linux: Atim; macOS/BSD: Atimespec
|
||||
// // Use reflection or build tags if naming differs.
|
||||
// // Linux:
|
||||
// if stat.Atim.Sec != 0 || stat.Atim.Nsec != 0 {
|
||||
// return time.Unix(int64(stat.Atim.Sec), int64(stat.Atim.Nsec)), true
|
||||
// }
|
||||
// // macOS/BSD example (uncomment if needed):
|
||||
// //return time.Unix(int64(stat.Atimespec.Sec), int64(stat.Atimespec.Nsec)), true
|
||||
// }
|
||||
// return time.Time{}, false
|
||||
// }
|
||||
|
||||
func appendFileInfo(info fs.FileInfo, out *CartFileInfo) *CartFileInfo {
|
||||
//sys := info.Sys()
|
||||
//fmt.Printf("sys type %T", sys)
|
||||
out.Size = info.Size()
|
||||
out.Modified = info.ModTime()
|
||||
//out.Accessed, _ = AccessTime(info)
|
||||
return out
|
||||
}
|
||||
|
||||
// var cartFileRe = regexp.MustCompile(`^(\d+)\.events\.log$`)
|
||||
|
||||
func listCartFiles(dir string) ([]*CartFileInfo, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []*CartFileInfo{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*CartFileInfo, 0)
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
id, valid := isValidFileId(e.Name())
|
||||
if !valid {
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
info.Sys()
|
||||
out = append(out, appendFileInfo(info, &CartFileInfo{
|
||||
ID: fmt.Sprintf("%d", id),
|
||||
CartId: cart.CartId(id),
|
||||
}))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func readRawLogLines(path string) ([]json.RawMessage, error) {
|
||||
fh, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer fh.Close()
|
||||
lines := make([]json.RawMessage, 0, 64)
|
||||
s := bufio.NewScanner(fh)
|
||||
// increase buffer to handle larger JSON lines
|
||||
buf := make([]byte, 0, 1024*1024)
|
||||
s.Buffer(buf, 1024*1024)
|
||||
for s.Scan() {
|
||||
line := s.Bytes()
|
||||
if line == nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if err := s.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func (fs *FileServer) CartsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := listCartFiles(fs.dataDir)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// sort by modified desc
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].Modified.After(list[j].Modified) })
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"count": len(list),
|
||||
"carts": list,
|
||||
})
|
||||
}
|
||||
|
||||
func (fs *FileServer) PromotionsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
fileName := filepath.Join(fs.dataDir, "promotions.json")
|
||||
if r.Method == http.MethodGet {
|
||||
file, err := os.Open(fileName)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
io.Copy(w, file)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
file, err := os.Create(fileName)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
io.Copy(file, r.Body)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
func (fs *FileServer) VoucherHandler(w http.ResponseWriter, r *http.Request) {
|
||||
fileName := filepath.Join(fs.dataDir, "vouchers.json")
|
||||
if r.Method == http.MethodGet {
|
||||
file, err := os.Open(fileName)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
io.Copy(w, file)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
file, err := os.Create(fileName)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
io.Copy(file, r.Body)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
func (fs *FileServer) PromotionPartHandler(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
if idStr == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, "missing id")
|
||||
return
|
||||
}
|
||||
_, ok := isValidId(idStr)
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, "invalid id %s", idStr)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
type JsonError struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func (fs *FileServer) CartHandler(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
if idStr == "" {
|
||||
writeJSON(w, http.StatusBadRequest, JsonError{Error: "missing id"})
|
||||
return
|
||||
}
|
||||
id, ok := isValidId(idStr)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid id"})
|
||||
return
|
||||
}
|
||||
// reconstruct state from event log if present
|
||||
grain := cart.NewCartGrain(id, time.Now())
|
||||
|
||||
err := fs.storage.LoadEvents(r.Context(), id, grain)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
path := filepath.Join(fs.dataDir, fmt.Sprintf("%d.events.log", id))
|
||||
info, err := os.Stat(path)
|
||||
if err != nil && errors.Is(err, os.ErrNotExist) {
|
||||
writeJSON(w, http.StatusNotFound, JsonError{Error: "cart not found"})
|
||||
return
|
||||
} else if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
lines, err := readRawLogLines(path)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": id,
|
||||
"cartId": cart.CartId(id).String(),
|
||||
"state": grain,
|
||||
"mutations": lines,
|
||||
"meta": map[string]any{
|
||||
"size": info.Size(),
|
||||
"modified": info.ModTime(),
|
||||
"path": path,
|
||||
},
|
||||
})
|
||||
}
|
||||
89
cmd/backoffice/fileserver_test.go
Normal file
89
cmd/backoffice/fileserver_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||
)
|
||||
|
||||
// TestAppendFileInfoRandomProjectFile picks a random existing .go source file in the
|
||||
// repository (from a small curated list to keep the test hermetic) and verifies
|
||||
// that appendFileInfo populates Size, Modified and System without mutating the
|
||||
// identity fields (ID, CartId). The randomness is only to satisfy the requirement
|
||||
// of using "a random project file"; the test behavior is deterministic enough for
|
||||
// CI because all chosen files are expected to exist.
|
||||
func TestAppendFileInfoRandomProjectFile(t *testing.T) {
|
||||
candidates := []string{
|
||||
filepath.FromSlash("../../pkg/cart/cart_id.go"),
|
||||
filepath.FromSlash("../../pkg/actor/grain.go"),
|
||||
filepath.FromSlash("../../cmd/cart/main.go"),
|
||||
}
|
||||
// Pick one at random.
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
path := candidates[rand.Intn(len(candidates))]
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat failed for %s: %v", path, err)
|
||||
}
|
||||
|
||||
// Pre-populate a CartFileInfo with identity fields.
|
||||
origID := "test-id"
|
||||
origCartId := cart.CartId(12345)
|
||||
cf := &CartFileInfo{ID: origID, CartId: origCartId}
|
||||
|
||||
// Call function under test.
|
||||
got := appendFileInfo(info, cf)
|
||||
|
||||
if got != cf {
|
||||
t.Fatalf("appendFileInfo should return the same pointer instance")
|
||||
}
|
||||
|
||||
if cf.ID != origID {
|
||||
t.Fatalf("ID mutated: expected %q got %q", origID, cf.ID)
|
||||
}
|
||||
if cf.CartId != origCartId {
|
||||
t.Fatalf("CartId mutated: expected %v got %v", origCartId, cf.CartId)
|
||||
}
|
||||
|
||||
if cf.Size != info.Size() {
|
||||
t.Fatalf("Size mismatch: expected %d got %d", info.Size(), cf.Size)
|
||||
}
|
||||
|
||||
mod := info.ModTime()
|
||||
// Allow small clock skew / coarse timestamp truncation.
|
||||
if cf.Modified.Before(mod.Add(-2*time.Second)) || cf.Modified.After(mod.Add(2*time.Second)) {
|
||||
t.Fatalf("Modified not within expected range: want ~%v got %v", mod, cf.Modified)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// TestAppendFileInfoTempFile creates a temporary file to ensure Size and Modified
|
||||
// are updated for a freshly written file with known content length.
|
||||
func TestAppendFileInfoTempFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "temp.events.log")
|
||||
content := []byte("hello world\nanother line\n")
|
||||
if err := os.WriteFile(path, content, 0644); err != nil {
|
||||
t.Fatalf("write temp file failed: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat temp file failed: %v", err)
|
||||
}
|
||||
|
||||
cf := &CartFileInfo{ID: "temp", CartId: cart.CartId(0)}
|
||||
appendFileInfo(info, cf)
|
||||
|
||||
if cf.Size != int64(len(content)) {
|
||||
t.Fatalf("expected Size %d got %d", len(content), cf.Size)
|
||||
}
|
||||
if cf.Modified.IsZero() {
|
||||
t.Fatalf("Modified should be set")
|
||||
}
|
||||
}
|
||||
248
cmd/backoffice/hub.go
Normal file
248
cmd/backoffice/hub.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Hub manages websocket clients and broadcasts messages to them.
|
||||
type Hub struct {
|
||||
register chan *Client
|
||||
unregister chan *Client
|
||||
broadcast chan []byte
|
||||
clients map[*Client]bool
|
||||
}
|
||||
|
||||
// Client represents a single websocket client connection.
|
||||
type Client struct {
|
||||
hub *Hub
|
||||
conn net.Conn
|
||||
send chan []byte
|
||||
}
|
||||
|
||||
// NewHub constructs a new Hub instance.
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
register: make(chan *Client),
|
||||
unregister: make(chan *Client),
|
||||
broadcast: make(chan []byte, 1024),
|
||||
clients: make(map[*Client]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the hub event loop.
|
||||
func (h *Hub) Run() {
|
||||
for {
|
||||
select {
|
||||
case c := <-h.register:
|
||||
h.clients[c] = true
|
||||
case c := <-h.unregister:
|
||||
if _, ok := h.clients[c]; ok {
|
||||
delete(h.clients, c)
|
||||
close(c.send)
|
||||
_ = c.conn.Close()
|
||||
}
|
||||
case msg := <-h.broadcast:
|
||||
for c := range h.clients {
|
||||
select {
|
||||
case c.send <- msg:
|
||||
default:
|
||||
// Client is slow or dead; drop it.
|
||||
delete(h.clients, c)
|
||||
close(c.send)
|
||||
_ = c.conn.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// computeAccept computes the Sec-WebSocket-Accept header value.
|
||||
func computeAccept(key string) string {
|
||||
const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||
h := sha1.New()
|
||||
h.Write([]byte(key + magic))
|
||||
return base64.StdEncoding.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// ServeWS upgrades the HTTP request to a WebSocket connection and registers a client.
|
||||
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") || strings.ToLower(r.Header.Get("Upgrade")) != "websocket" {
|
||||
http.Error(w, "upgrade required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
key := r.Header.Get("Sec-WebSocket-Key")
|
||||
if key == "" {
|
||||
http.Error(w, "missing Sec-WebSocket-Key", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
accept := computeAccept(key)
|
||||
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "websocket not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
conn, buf, err := hj.Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Write the upgrade response
|
||||
response := "HTTP/1.1 101 Switching Protocols\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Connection: Upgrade\r\n" +
|
||||
"Sec-WebSocket-Accept: " + accept + "\r\n" +
|
||||
"\r\n"
|
||||
if _, err := buf.WriteString(response); err != nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
if err := buf.Flush(); err != nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
hub: h,
|
||||
conn: conn,
|
||||
send: make(chan []byte, 256),
|
||||
}
|
||||
h.register <- client
|
||||
go client.writePump()
|
||||
go client.readPump()
|
||||
}
|
||||
|
||||
// writeWSFrame writes a single WebSocket frame to the writer.
|
||||
func writeWSFrame(w io.Writer, opcode byte, payload []byte) error {
|
||||
// FIN set, opcode as provided
|
||||
header := []byte{0x80 | (opcode & 0x0F)}
|
||||
l := len(payload)
|
||||
switch {
|
||||
case l < 126:
|
||||
header = append(header, byte(l))
|
||||
case l <= 65535:
|
||||
ext := make([]byte, 2)
|
||||
binary.BigEndian.PutUint16(ext, uint16(l))
|
||||
header = append(header, 126)
|
||||
header = append(header, ext...)
|
||||
default:
|
||||
ext := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(ext, uint64(l))
|
||||
header = append(header, 127)
|
||||
header = append(header, ext...)
|
||||
}
|
||||
if _, err := w.Write(header); err != nil {
|
||||
return err
|
||||
}
|
||||
if l > 0 {
|
||||
if _, err := w.Write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readPump handles control frames from the client and discards other incoming frames.
|
||||
// This server is broadcast-only to clients.
|
||||
func (c *Client) readPump() {
|
||||
defer func() {
|
||||
c.hub.unregister <- c
|
||||
}()
|
||||
reader := bufio.NewReader(c.conn)
|
||||
for {
|
||||
// Read first two bytes
|
||||
b1, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
b2, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
opcode := b1 & 0x0F
|
||||
masked := (b2 & 0x80) != 0
|
||||
length := int64(b2 & 0x7F)
|
||||
if length == 126 {
|
||||
ext := make([]byte, 2)
|
||||
if _, err := io.ReadFull(reader, ext); err != nil {
|
||||
return
|
||||
}
|
||||
length = int64(binary.BigEndian.Uint16(ext))
|
||||
} else if length == 127 {
|
||||
ext := make([]byte, 8)
|
||||
if _, err := io.ReadFull(reader, ext); err != nil {
|
||||
return
|
||||
}
|
||||
length = int64(binary.BigEndian.Uint64(ext))
|
||||
}
|
||||
var maskKey [4]byte
|
||||
if masked {
|
||||
if _, err := io.ReadFull(reader, maskKey[:]); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Ping -> Pong
|
||||
if opcode == 0x9 && length <= 125 {
|
||||
payload := make([]byte, length)
|
||||
if _, err := io.ReadFull(reader, payload); err != nil {
|
||||
return
|
||||
}
|
||||
// Unmask if masked
|
||||
if masked {
|
||||
for i := int64(0); i < length; i++ {
|
||||
payload[i] ^= maskKey[i%4]
|
||||
}
|
||||
}
|
||||
_ = writeWSFrame(c.conn, 0xA, payload) // best-effort pong
|
||||
continue
|
||||
}
|
||||
|
||||
// Close frame
|
||||
if opcode == 0x8 {
|
||||
// Drain payload if any, then exit
|
||||
if _, err := io.CopyN(io.Discard, reader, length); err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// For other frames, just discard payload
|
||||
if _, err := io.CopyN(io.Discard, reader, length); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writePump sends queued messages to the client and pings periodically to keep the connection alive.
|
||||
func (c *Client) writePump() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
_ = c.conn.Close()
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case msg, ok := <-c.send:
|
||||
if !ok {
|
||||
// try to send close frame
|
||||
_ = writeWSFrame(c.conn, 0x8, nil)
|
||||
return
|
||||
}
|
||||
if err := writeWSFrame(c.conn, 0x1, msg); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
// Send a ping to keep connections alive behind proxies
|
||||
_ = writeWSFrame(c.conn, 0x9, []byte("ping"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,152 @@
|
||||
package main
|
||||
|
||||
func main() {
|
||||
// Your code here
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
actor "git.tornberg.me/go-cart-actor/pkg/actor"
|
||||
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||
"github.com/matst80/slask-finder/pkg/messaging"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
type CartFileInfo struct {
|
||||
ID string `json:"id"`
|
||||
CartId cart.CartId `json:"cartId"`
|
||||
Size int64 `json:"size"`
|
||||
Modified time.Time `json:"modified"`
|
||||
}
|
||||
|
||||
func envOrDefault(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func startMutationConsumer(ctx context.Context, conn *amqp.Connection, hub *Hub) error {
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return err
|
||||
}
|
||||
msgs, err := messaging.DeclareBindAndConsume(ch, "cart", "mutation")
|
||||
if err != nil {
|
||||
_ = ch.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer ch.Close()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case m, ok := <-msgs:
|
||||
if !ok {
|
||||
log.Fatalf("connection closed")
|
||||
continue
|
||||
}
|
||||
// Log and broadcast to all websocket clients
|
||||
log.Printf("mutation event: %s", string(m.Body))
|
||||
|
||||
if hub != nil {
|
||||
select {
|
||||
case hub.broadcast <- m.Body:
|
||||
default:
|
||||
// if hub queue is full, drop to avoid blocking
|
||||
}
|
||||
}
|
||||
if err := m.Ack(false); err != nil {
|
||||
log.Printf("error acknowledging message: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
dataDir := envOrDefault("DATA_DIR", "data")
|
||||
addr := envOrDefault("ADDR", ":8080")
|
||||
amqpURL := os.Getenv("AMQP_URL")
|
||||
|
||||
_ = os.MkdirAll(dataDir, 0755)
|
||||
|
||||
reg := cart.NewCartMultationRegistry()
|
||||
diskStorage := actor.NewDiskStorage[cart.CartGrain](dataDir, reg)
|
||||
|
||||
fs := NewFileServer(dataDir, diskStorage)
|
||||
|
||||
hub := NewHub()
|
||||
go hub.Run()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /carts", fs.CartsHandler)
|
||||
mux.HandleFunc("GET /cart/{id}", fs.CartHandler)
|
||||
mux.HandleFunc("/promotions", fs.PromotionsHandler)
|
||||
mux.HandleFunc("/vouchers", fs.VoucherHandler)
|
||||
mux.HandleFunc("/promotion/{id}", fs.PromotionPartHandler)
|
||||
|
||||
mux.HandleFunc("/ws", hub.ServeWS)
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
// Global CORS middleware allowing all origins and handling preflight
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||||
w.Header().Set("Access-Control-Expose-Headers", "*")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
mux.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if amqpURL != "" {
|
||||
conn, err := amqp.Dial(amqpURL)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect to RabbitMQ: %v", err)
|
||||
}
|
||||
if err := startMutationConsumer(ctx, conn, hub); err != nil {
|
||||
log.Printf("AMQP listener disabled: %v", err)
|
||||
} else {
|
||||
log.Printf("AMQP listener connected")
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("backoffice HTTP listening on %s (dataDir=%s)", addr, dataDir)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("http server error: %v", err)
|
||||
}
|
||||
|
||||
// server stopped
|
||||
}
|
||||
|
||||
@@ -9,53 +9,56 @@ import (
|
||||
)
|
||||
|
||||
type AmqpOrderHandler struct {
|
||||
Url string
|
||||
Connection *amqp.Connection
|
||||
Channel *amqp.Channel
|
||||
conn *amqp.Connection
|
||||
queue *amqp.Queue
|
||||
}
|
||||
|
||||
func (h *AmqpOrderHandler) Connect() error {
|
||||
conn, err := amqp.Dial(h.Url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to RabbitMQ: %w", err)
|
||||
func NewAmqpOrderHandler(conn *amqp.Connection) *AmqpOrderHandler {
|
||||
return &AmqpOrderHandler{
|
||||
conn: conn,
|
||||
}
|
||||
h.Connection = conn
|
||||
}
|
||||
|
||||
ch, err := conn.Channel()
|
||||
func (h *AmqpOrderHandler) DefineQueue() error {
|
||||
ch, err := h.conn.Channel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open a channel: %w", err)
|
||||
}
|
||||
h.Channel = ch
|
||||
defer ch.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AmqpOrderHandler) Close() error {
|
||||
if h.Channel != nil {
|
||||
h.Channel.Close()
|
||||
}
|
||||
if h.Connection != nil {
|
||||
return h.Connection.Close()
|
||||
queue, err := ch.QueueDeclare(
|
||||
"order-queue", // name
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to declare an exchange: %w", err)
|
||||
}
|
||||
h.queue = &queue
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AmqpOrderHandler) OrderCompleted(body []byte) error {
|
||||
ch, err := h.conn.Channel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open a channel: %w", err)
|
||||
}
|
||||
defer ch.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := h.Channel.PublishWithContext(ctx,
|
||||
"orders", // exchange
|
||||
"new", // routing key
|
||||
return ch.PublishWithContext(ctx,
|
||||
"", // exchange
|
||||
h.queue.Name, // routing key
|
||||
false, // mandatory
|
||||
false, // immediate
|
||||
amqp.Publishing{
|
||||
//DeliveryMode: amqp.,
|
||||
ContentType: "application/json",
|
||||
Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to publish a message: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||
)
|
||||
|
||||
// CheckoutMeta carries the external / URL metadata required to build a
|
||||
@@ -12,8 +14,6 @@ type CheckoutMeta struct {
|
||||
Terms string
|
||||
Checkout string
|
||||
Confirmation string
|
||||
Validation string
|
||||
Push string
|
||||
Country string
|
||||
Currency string // optional override (defaults to "SEK" if empty)
|
||||
Locale string // optional override (defaults to "sv-se" if empty)
|
||||
@@ -33,7 +33,7 @@ type CheckoutMeta struct {
|
||||
//
|
||||
// If you later need to support different tax rates per line, you can extend
|
||||
// CartItem / Delivery to expose that data and propagate it here.
|
||||
func BuildCheckoutOrderPayload(grain *CartGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
|
||||
func BuildCheckoutOrderPayload(grain *cart.CartGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
|
||||
if grain == nil {
|
||||
return nil, nil, fmt.Errorf("nil grain")
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func BuildCheckoutOrderPayload(grain *CartGrain, meta *CheckoutMeta) ([]byte, *C
|
||||
Name: it.Meta.Name,
|
||||
Quantity: it.Quantity,
|
||||
UnitPrice: int(it.Price.IncVat),
|
||||
TaxRate: 2500, // TODO: derive if variable tax rates are introduced
|
||||
TaxRate: it.Tax, // TODO: derive if variable tax rates are introduced
|
||||
QuantityUnit: "st",
|
||||
TotalAmount: int(it.TotalPrice.IncVat),
|
||||
TotalTaxAmount: int(it.TotalPrice.TotalVat()),
|
||||
@@ -105,8 +105,9 @@ func BuildCheckoutOrderPayload(grain *CartGrain, meta *CheckoutMeta) ([]byte, *C
|
||||
Terms: meta.Terms,
|
||||
Checkout: meta.Checkout,
|
||||
Confirmation: meta.Confirmation,
|
||||
Validation: meta.Validation,
|
||||
Push: meta.Push,
|
||||
Notification: "https://cart.tornberg.me/notification",
|
||||
Validation: "https://cart.tornberg.me/validate",
|
||||
Push: "https://cart.tornberg.me/push?order_id={checkout.order.id}",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
203
cmd/cart/checkout_server.go
Normal file
203
cmd/cart/checkout_server.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||
"git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
"git.tornberg.me/mats/go-redis-inventory/pkg/inventory"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
var tpl = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>s10r testing - checkout</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
%s
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
func (a *App) getGrainFromOrder(ctx context.Context, order *CheckoutOrder) (*cart.CartGrain, error) {
|
||||
cartId, ok := cart.ParseCartId(order.MerchantReference1)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1)
|
||||
}
|
||||
grain, err := a.pool.Get(ctx, uint64(cartId))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get cart grain: %w", err)
|
||||
}
|
||||
return grain, nil
|
||||
}
|
||||
|
||||
func (a *App) HandleCheckoutRequests(amqpUrl string, mux *http.ServeMux, inventoryService inventory.InventoryService) {
|
||||
conn, err := amqp.Dial(amqpUrl)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect to RabbitMQ: %v", err)
|
||||
}
|
||||
|
||||
amqpListener := actor.NewAmqpListener(conn, func(id uint64, msg []actor.ApplyResult) (any, error) {
|
||||
return &CartChangeEvent{
|
||||
CartId: cart.CartId(id),
|
||||
Mutations: msg,
|
||||
}, nil
|
||||
})
|
||||
amqpListener.DefineTopics()
|
||||
a.pool.AddListener(amqpListener)
|
||||
orderHandler := NewAmqpOrderHandler(conn)
|
||||
orderHandler.DefineQueue()
|
||||
|
||||
mux.HandleFunc("/push", func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Klarna order confirmation push, method: %s", r.Method)
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
orderId := r.URL.Query().Get("order_id")
|
||||
log.Printf("Order confirmation push: %s", orderId)
|
||||
|
||||
order, err := a.klarnaClient.GetOrder(r.Context(), orderId)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error creating request: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
grain, err := a.getGrainFromOrder(r.Context(), order)
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "Unable to get grain from klarna order", "error", err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if inventoryService != nil {
|
||||
inventoryRequests := getInventoryRequests(grain.Items)
|
||||
err = inventoryService.ReserveInventory(r.Context(), inventoryRequests...)
|
||||
|
||||
if err != nil {
|
||||
logger.WarnContext(r.Context(), "placeorder inventory reservation failed")
|
||||
w.WriteHeader(http.StatusNotAcceptable)
|
||||
return
|
||||
}
|
||||
a.pool.Apply(r.Context(), uint64(grain.Id), &messages.InventoryReserved{
|
||||
Id: grain.Id.String(),
|
||||
Status: "success",
|
||||
})
|
||||
}
|
||||
|
||||
err = confirmOrder(r.Context(), order, orderHandler)
|
||||
if err != nil {
|
||||
log.Printf("Error confirming order: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = triggerOrderCompleted(r.Context(), a.server, order)
|
||||
if err != nil {
|
||||
log.Printf("Error processing cart message: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
err = a.klarnaClient.AcknowledgeOrder(r.Context(), orderId)
|
||||
if err != nil {
|
||||
log.Printf("Error acknowledging order: %v\n", err)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /checkout", a.server.CheckoutHandler(func(order *CheckoutOrder, w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, err := fmt.Fprintf(w, tpl, order.HTMLSnippet)
|
||||
return err
|
||||
}))
|
||||
|
||||
mux.HandleFunc("GET /confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
orderId := r.PathValue("order_id")
|
||||
order, err := a.klarnaClient.GetOrder(r.Context(), orderId)
|
||||
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if order.Status == "checkout_complete" {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "cartid",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
Expires: time.Unix(0, 0),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, tpl, order.HTMLSnippet)
|
||||
})
|
||||
mux.HandleFunc("/notification", func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Klarna order notification, method: %s", r.Method)
|
||||
logger.InfoContext(r.Context(), "Klarna order notification received", "method", r.Method)
|
||||
if r.Method != "POST" {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
order := &CheckoutOrder{}
|
||||
err := json.NewDecoder(r.Body).Decode(order)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
log.Printf("Klarna order notification: %s", order.ID)
|
||||
logger.InfoContext(r.Context(), "Klarna order notification received", "order_id", order.ID)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
mux.HandleFunc("POST /validate", func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Klarna order validation, method: %s", r.Method)
|
||||
if r.Method != "POST" {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
order := &CheckoutOrder{}
|
||||
err := json.NewDecoder(r.Body).Decode(order)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
logger.InfoContext(r.Context(), "Klarna order validation received", "order_id", order.ID, "cart_id", order.MerchantReference1)
|
||||
grain, err := a.getGrainFromOrder(r.Context(), order)
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "Unable to get grain from klarna order", "error", err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if inventoryService != nil {
|
||||
inventoryRequests := getInventoryRequests(grain.Items)
|
||||
_, err = inventoryService.ReservationCheck(r.Context(), inventoryRequests...)
|
||||
if err != nil {
|
||||
logger.WarnContext(r.Context(), "placeorder inventory check failed")
|
||||
w.WriteHeader(http.StatusNotAcceptable)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
}
|
||||
60
cmd/cart/k8s-host-discovery.go
Normal file
60
cmd/cart/k8s-host-discovery.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||
"git.tornberg.me/go-cart-actor/pkg/discovery"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
)
|
||||
|
||||
func GetDiscovery() discovery.Discovery {
|
||||
if podIp == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
config, kerr := rest.InClusterConfig()
|
||||
|
||||
if kerr != nil {
|
||||
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
|
||||
}
|
||||
client, err := kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating client: %v\n", err)
|
||||
}
|
||||
return discovery.NewK8sDiscovery(client)
|
||||
}
|
||||
|
||||
func UseDiscovery(pool actor.GrainPool[*cart.CartGrain]) {
|
||||
|
||||
go func(hw discovery.Discovery) {
|
||||
if hw == nil {
|
||||
log.Print("No discovery service available")
|
||||
return
|
||||
}
|
||||
ch, err := hw.Watch()
|
||||
if err != nil {
|
||||
log.Printf("Discovery error: %v", err)
|
||||
return
|
||||
}
|
||||
for evt := range ch {
|
||||
if evt.Host == "" {
|
||||
continue
|
||||
}
|
||||
switch evt.IsReady {
|
||||
case false:
|
||||
if pool.IsKnown(evt.Host) {
|
||||
log.Printf("Host %s is not ready, removing", evt.Host)
|
||||
pool.RemoveHost(evt.Host)
|
||||
}
|
||||
default:
|
||||
if !pool.IsKnown(evt.Host) {
|
||||
log.Printf("Discovered host %s", evt.Host)
|
||||
pool.AddRemoteHost(evt.Host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}(GetDiscovery())
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -30,11 +31,14 @@ const (
|
||||
KlarnaPlaygroundUrl = "https://api.playground.klarna.com"
|
||||
)
|
||||
|
||||
func (k *KlarnaClient) GetOrder(orderId string) (*CheckoutOrder, error) {
|
||||
func (k *KlarnaClient) GetOrder(ctx context.Context, orderId string) (*CheckoutOrder, error) {
|
||||
req, err := http.NewRequest("GET", k.Url+"/checkout/v3/orders/"+orderId, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spanCtx, span := tracer.Start(ctx, "Get klarna order")
|
||||
defer span.End()
|
||||
req = req.WithContext(spanCtx)
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.SetBasicAuth(k.UserName, k.Password)
|
||||
|
||||
@@ -64,13 +68,15 @@ func (k *KlarnaClient) getOrderResponse(res *http.Response) (*CheckoutOrder, err
|
||||
return nil, fmt.Errorf("%s", res.Status)
|
||||
}
|
||||
|
||||
func (k *KlarnaClient) CreateOrder(reader io.Reader) (*CheckoutOrder, error) {
|
||||
func (k *KlarnaClient) CreateOrder(ctx context.Context, reader io.Reader) (*CheckoutOrder, error) {
|
||||
//bytes.NewReader(reply.Payload)
|
||||
req, err := http.NewRequest("POST", k.Url+"/checkout/v3/orders", reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spanCtx, span := tracer.Start(ctx, "Create klarna order")
|
||||
defer span.End()
|
||||
req = req.WithContext(spanCtx)
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.SetBasicAuth(k.UserName, k.Password)
|
||||
|
||||
@@ -82,13 +88,16 @@ func (k *KlarnaClient) CreateOrder(reader io.Reader) (*CheckoutOrder, error) {
|
||||
return k.getOrderResponse(res)
|
||||
}
|
||||
|
||||
func (k *KlarnaClient) UpdateOrder(orderId string, reader io.Reader) (*CheckoutOrder, error) {
|
||||
func (k *KlarnaClient) UpdateOrder(ctx context.Context, orderId string, reader io.Reader) (*CheckoutOrder, error) {
|
||||
//bytes.NewReader(reply.Payload)
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkout/v3/orders/%s", k.Url, orderId), reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spanCtx, span := tracer.Start(ctx, "Update klarna order")
|
||||
defer span.End()
|
||||
req = req.WithContext(spanCtx)
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.SetBasicAuth(k.UserName, k.Password)
|
||||
|
||||
@@ -100,12 +109,14 @@ func (k *KlarnaClient) UpdateOrder(orderId string, reader io.Reader) (*CheckoutO
|
||||
return k.getOrderResponse(res)
|
||||
}
|
||||
|
||||
func (k *KlarnaClient) AbortOrder(orderId string) error {
|
||||
func (k *KlarnaClient) AbortOrder(ctx context.Context, orderId string) error {
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkout/v3/orders/%s/abort", k.Url, orderId), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
spanCtx, span := tracer.Start(ctx, "Abort klarna order")
|
||||
defer span.End()
|
||||
req = req.WithContext(spanCtx)
|
||||
req.SetBasicAuth(k.UserName, k.Password)
|
||||
|
||||
_, err = http.DefaultClient.Do(req)
|
||||
@@ -113,11 +124,14 @@ func (k *KlarnaClient) AbortOrder(orderId string) error {
|
||||
}
|
||||
|
||||
// ordermanagement/v1/orders/{order_id}/acknowledge
|
||||
func (k *KlarnaClient) AcknowledgeOrder(orderId string) error {
|
||||
func (k *KlarnaClient) AcknowledgeOrder(ctx context.Context, orderId string) error {
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("%s/ordermanagement/v1/orders/%s/acknowledge", k.Url, orderId), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spanCtx, span := tracer.Start(ctx, "Acknowledge klarna order")
|
||||
defer span.End()
|
||||
req = req.WithContext(spanCtx)
|
||||
id := uuid.New()
|
||||
|
||||
req.SetBasicAuth(k.UserName, k.Password)
|
||||
|
||||
433
cmd/cart/main.go
433
cmd/cart/main.go
@@ -1,28 +1,30 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||
"git.tornberg.me/go-cart-actor/pkg/discovery"
|
||||
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
"git.tornberg.me/go-cart-actor/pkg/promotions"
|
||||
"git.tornberg.me/go-cart-actor/pkg/proxy"
|
||||
"git.tornberg.me/go-cart-actor/pkg/voucher"
|
||||
"git.tornberg.me/mats/go-redis-inventory/pkg/inventory"
|
||||
"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"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -30,14 +32,6 @@ var (
|
||||
Name: "cart_grain_spawned_total",
|
||||
Help: "The total number of spawned grains",
|
||||
})
|
||||
grainMutations = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "cart_grain_mutations_total",
|
||||
Help: "The total number of mutations",
|
||||
})
|
||||
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "cart_grain_lookups_total",
|
||||
Help: "The total number of lookups",
|
||||
})
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -45,26 +39,16 @@ func init() {
|
||||
}
|
||||
|
||||
type App struct {
|
||||
pool *actor.SimpleGrainPool[CartGrain]
|
||||
pool *actor.SimpleGrainPool[cart.CartGrain]
|
||||
server *PoolServer
|
||||
klarnaClient *KlarnaClient
|
||||
}
|
||||
|
||||
var podIp = os.Getenv("POD_IP")
|
||||
var name = os.Getenv("POD_NAME")
|
||||
var amqpUrl = os.Getenv("AMQP_URL")
|
||||
|
||||
var tpl = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>s10r testing - checkout</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
%s
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
var redisAddress = os.Getenv("REDIS_ADDRESS")
|
||||
var redisPassword = os.Getenv("REDIS_PASSWORD")
|
||||
|
||||
func getCountryFromHost(host string) string {
|
||||
if strings.Contains(strings.ToLower(host), "-no") {
|
||||
@@ -76,86 +60,57 @@ func getCountryFromHost(host string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func GetDiscovery() discovery.Discovery {
|
||||
if podIp == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
config, kerr := rest.InClusterConfig()
|
||||
|
||||
if kerr != nil {
|
||||
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
|
||||
}
|
||||
client, err := kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating client: %v\n", err)
|
||||
}
|
||||
return discovery.NewK8sDiscovery(client)
|
||||
}
|
||||
|
||||
type MutationContext struct {
|
||||
VoucherService voucher.Service
|
||||
}
|
||||
|
||||
type CartChangeEvent struct {
|
||||
CartId cart.CartId `json:"cartId"`
|
||||
Mutations []actor.ApplyResult `json:"mutations"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
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{}
|
||||
promotionData, err := promotions.LoadStateFile("data/promotions.json")
|
||||
if err != nil {
|
||||
log.Printf("Error loading promotions: %v\n", err)
|
||||
}
|
||||
|
||||
log.Printf("loaded %d promotions", len(promotionData.State.Promotions))
|
||||
|
||||
promotionService := promotions.NewPromotionService(nil)
|
||||
|
||||
reg := cart.NewCartMultationRegistry()
|
||||
reg.RegisterProcessor(
|
||||
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
|
||||
_, span := tracer.Start(ctx, "Totals and promotions")
|
||||
defer span.End()
|
||||
g.UpdateTotals()
|
||||
|
||||
promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()), promotions.WithCustomerSegment("vip"))
|
||||
_, actions := promotionService.EvaluateAll(promotionData.State.Promotions, promotionCtx)
|
||||
for _, action := range actions {
|
||||
log.Printf("apply: %+v", action)
|
||||
g.UpdateTotals()
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
diskStorage := actor.NewDiskStorage[CartGrain]("data", reg)
|
||||
poolConfig := actor.GrainPoolConfig[CartGrain]{
|
||||
|
||||
diskStorage := actor.NewDiskStorage[cart.CartGrain]("data", reg)
|
||||
poolConfig := actor.GrainPoolConfig[cart.CartGrain]{
|
||||
MutationRegistry: reg,
|
||||
Storage: diskStorage,
|
||||
Spawn: func(id uint64) (actor.Grain[CartGrain], error) {
|
||||
Spawn: func(ctx context.Context, id uint64) (actor.Grain[cart.CartGrain], error) {
|
||||
_, span := tracer.Start(ctx, fmt.Sprintf("Spawn cart id %d", id))
|
||||
defer span.End()
|
||||
grainSpawns.Inc()
|
||||
ret := &CartGrain{
|
||||
lastItemId: 0,
|
||||
lastDeliveryId: 0,
|
||||
Deliveries: []*CartDelivery{},
|
||||
Id: CartId(id),
|
||||
Items: []*CartItem{},
|
||||
TotalPrice: NewPrice(),
|
||||
}
|
||||
ret := cart.NewCartGrain(id, time.Now())
|
||||
// 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)
|
||||
err := diskStorage.LoadEvents(ctx, id, ret)
|
||||
|
||||
return ret, err
|
||||
},
|
||||
@@ -171,66 +126,71 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating cart pool: %v\n", err)
|
||||
}
|
||||
app := &App{
|
||||
pool: pool,
|
||||
|
||||
pool.SetPubSub(actor.NewPubSub(func(id uint64, event actor.Event) {
|
||||
grain, _ := pool.Get(context.Background(), id)
|
||||
if sub, ok := any(grain).(actor.Subscribable); ok {
|
||||
sub.Notify(event)
|
||||
}
|
||||
}))
|
||||
|
||||
klarnaClient := NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: redisAddress,
|
||||
Password: redisPassword,
|
||||
DB: 0,
|
||||
})
|
||||
inventoryService, err := inventory.NewRedisInventoryService(rdb)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating inventory service: %v\n", err)
|
||||
}
|
||||
|
||||
grpcSrv, err := actor.NewControlServer[*CartGrain](controlPlaneConfig, pool)
|
||||
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, inventoryService)
|
||||
|
||||
app := &App{
|
||||
pool: pool,
|
||||
server: syncedServer,
|
||||
klarnaClient: klarnaClient,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
debugMux := http.NewServeMux()
|
||||
|
||||
if amqpUrl == "" {
|
||||
log.Printf("no connection to amqp defined")
|
||||
} else {
|
||||
app.HandleCheckoutRequests(amqpUrl, mux, inventoryService)
|
||||
}
|
||||
|
||||
grpcSrv, err := actor.NewControlServer[*cart.CartGrain](controlPlaneConfig, pool)
|
||||
if err != nil {
|
||||
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
|
||||
}
|
||||
defer grpcSrv.GracefulStop()
|
||||
|
||||
go diskStorage.SaveLoop(10 * time.Second)
|
||||
// go diskStorage.SaveLoop(10 * time.Second)
|
||||
UseDiscovery(pool)
|
||||
|
||||
go func(hw discovery.Discovery) {
|
||||
if hw == nil {
|
||||
log.Print("No discovery service available")
|
||||
return
|
||||
}
|
||||
ch, err := hw.Watch()
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer stop()
|
||||
|
||||
otelShutdown, err := setupOTelSDK(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Discovery error: %v", err)
|
||||
return
|
||||
log.Fatalf("Unable to start otel %v", err)
|
||||
}
|
||||
for evt := range ch {
|
||||
if evt.Host == "" {
|
||||
continue
|
||||
}
|
||||
switch evt.Type {
|
||||
case watch.Deleted:
|
||||
if pool.IsKnown(evt.Host) {
|
||||
pool.RemoveHost(evt.Host)
|
||||
}
|
||||
default:
|
||||
if !pool.IsKnown(evt.Host) {
|
||||
log.Printf("Discovered host %s", evt.Host)
|
||||
pool.AddRemote(evt.Host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}(GetDiscovery())
|
||||
|
||||
orderHandler := &AmqpOrderHandler{
|
||||
Url: amqpUrl,
|
||||
}
|
||||
klarnaClient := NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
|
||||
|
||||
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient)
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/cart/", http.StripPrefix("/cart", syncedServer.Serve()))
|
||||
syncedServer.Serve(mux)
|
||||
// only for local
|
||||
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
||||
pool.AddRemote(r.PathValue("host"))
|
||||
})
|
||||
// mux.HandleFunc("GET /save", app.HandleSave)
|
||||
//mux.HandleFunc("/", app.RewritePath)
|
||||
mux.HandleFunc("/debug/pprof/", pprof.Index)
|
||||
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
||||
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
||||
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||||
mux.Handle("/metrics", promhttp.Handler())
|
||||
|
||||
debugMux.HandleFunc("/debug/pprof/", pprof.Index)
|
||||
debugMux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||
debugMux.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
||||
debugMux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
||||
debugMux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||||
debugMux.Handle("/metrics", promhttp.Handler())
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Grain pool health: simple capacity check (mirrors previous GrainHandler.IsHealthy)
|
||||
grainCount, capacity := app.pool.LocalUsage()
|
||||
@@ -256,152 +216,6 @@ func main() {
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
mux.HandleFunc("/checkout", func(w http.ResponseWriter, r *http.Request) {
|
||||
orderId := r.URL.Query().Get("order_id")
|
||||
order := &CheckoutOrder{}
|
||||
|
||||
if orderId == "" {
|
||||
cookie, err := r.Cookie("cartid")
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
if cookie.Value == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("no cart id to checkout is empty"))
|
||||
return
|
||||
}
|
||||
parsed, ok := ParseCartId(cookie.Value)
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("invalid cart id format"))
|
||||
return
|
||||
}
|
||||
cartId := parsed
|
||||
syncedServer.ProxyHandler(func(w http.ResponseWriter, r *http.Request, cartId CartId) error {
|
||||
order, err = syncedServer.CreateOrUpdateCheckout(r.Host, cartId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, tpl, order.HTMLSnippet)
|
||||
return nil
|
||||
})(cartId, w, r)
|
||||
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
}
|
||||
|
||||
// v2: Apply now returns *CartGrain; order creation handled inside grain (no payload to unmarshal)
|
||||
} else {
|
||||
order, err = klarnaClient.GetOrder(orderId)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, tpl, order.HTMLSnippet)
|
||||
}
|
||||
|
||||
})
|
||||
mux.HandleFunc("/confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
orderId := r.PathValue("order_id")
|
||||
order, err := klarnaClient.GetOrder(orderId)
|
||||
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if order.Status == "checkout_complete" {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "cartid",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
Expires: time.Unix(0, 0),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, tpl, order.HTMLSnippet)
|
||||
})
|
||||
mux.HandleFunc("/validate", func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Klarna order validation, method: %s", r.Method)
|
||||
if r.Method != "POST" {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
order := &CheckoutOrder{}
|
||||
err := json.NewDecoder(r.Body).Decode(order)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
log.Printf("Klarna order validation: %s", order.ID)
|
||||
//err = confirmOrder(order, orderHandler)
|
||||
//if err != nil {
|
||||
// log.Printf("Error validating order: %v\n", err)
|
||||
// w.WriteHeader(http.StatusInternalServerError)
|
||||
// return
|
||||
//}
|
||||
//
|
||||
//err = triggerOrderCompleted(err, syncedServer, order)
|
||||
//if err != nil {
|
||||
// log.Printf("Error processing cart message: %v\n", err)
|
||||
// w.WriteHeader(http.StatusInternalServerError)
|
||||
// return
|
||||
//}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
mux.HandleFunc("/push", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
orderId := r.URL.Query().Get("order_id")
|
||||
log.Printf("Order confirmation push: %s", orderId)
|
||||
|
||||
order, err := klarnaClient.GetOrder(orderId)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error creating request: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = confirmOrder(order, orderHandler)
|
||||
if err != nil {
|
||||
log.Printf("Error confirming order: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = triggerOrderCompleted(syncedServer, order)
|
||||
if err != nil {
|
||||
log.Printf("Error processing cart message: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
err = klarnaClient.AcknowledgeOrder(orderId)
|
||||
if err != nil {
|
||||
log.Printf("Error acknowledging order: %v\n", err)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("1.0.0"))
|
||||
@@ -409,49 +223,64 @@ func main() {
|
||||
|
||||
mux.HandleFunc("/openapi.json", ServeEmbeddedOpenAPI)
|
||||
|
||||
sigs := make(chan os.Signal, 1)
|
||||
done := make(chan bool, 1)
|
||||
signal.Notify(sigs, syscall.SIGTERM)
|
||||
srv := &http.Server{
|
||||
Addr: ":8080",
|
||||
BaseContext: func(net.Listener) context.Context { return ctx },
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 20 * time.Second,
|
||||
Handler: otelhttp.NewHandler(mux, "/"),
|
||||
}
|
||||
|
||||
go func() {
|
||||
sig := <-sigs
|
||||
fmt.Println("Shutting down due to signal:", sig)
|
||||
defer func() {
|
||||
|
||||
fmt.Println("Shutting down due to signal")
|
||||
otelShutdown(context.Background())
|
||||
diskStorage.Close()
|
||||
pool.Close()
|
||||
|
||||
done <- true
|
||||
}()
|
||||
|
||||
srvErr := make(chan error, 1)
|
||||
go func() {
|
||||
srvErr <- srv.ListenAndServe()
|
||||
}()
|
||||
|
||||
log.Print("Server started at port 8080")
|
||||
go http.ListenAndServe(":8080", mux)
|
||||
<-done
|
||||
|
||||
go http.ListenAndServe(":8081", debugMux)
|
||||
|
||||
select {
|
||||
case err = <-srvErr:
|
||||
// Error when starting HTTP server.
|
||||
log.Fatalf("Unable to start server: %v", err)
|
||||
case <-ctx.Done():
|
||||
// Wait for first CTRL+C.
|
||||
// Stop receiving signal notifications as soon as possible.
|
||||
stop()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func triggerOrderCompleted(syncedServer *PoolServer, order *CheckoutOrder) error {
|
||||
func triggerOrderCompleted(ctx context.Context, syncedServer *PoolServer, order *CheckoutOrder) error {
|
||||
mutation := &messages.OrderCreated{
|
||||
OrderId: order.ID,
|
||||
Status: order.Status,
|
||||
}
|
||||
cid, ok := ParseCartId(order.MerchantReference1)
|
||||
cid, ok := cart.ParseCartId(order.MerchantReference1)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1)
|
||||
}
|
||||
_, applyErr := syncedServer.Apply(uint64(cid), mutation)
|
||||
_, applyErr := syncedServer.Apply(ctx, uint64(cid), mutation)
|
||||
|
||||
return applyErr
|
||||
}
|
||||
|
||||
func confirmOrder(order *CheckoutOrder, orderHandler *AmqpOrderHandler) error {
|
||||
func confirmOrder(ctx context.Context, order *CheckoutOrder, orderHandler *AmqpOrderHandler) error {
|
||||
orderToSend, err := json.Marshal(order)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = orderHandler.Connect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer orderHandler.Close()
|
||||
|
||||
err = orderHandler.OrderCompleted(orderToSend)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/cart/": {
|
||||
"/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.",
|
||||
@@ -263,6 +263,76 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/voucher": {
|
||||
"put": {
|
||||
"summary": "Add voucher to cart",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/AddVoucherRequest" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Voucher added",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid body" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/subscription-details": {
|
||||
"put": {
|
||||
"summary": "Upsert subscription details",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpsertSubscriptionDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Subscription details updated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid body" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/voucher/{voucherId}": {
|
||||
"delete": {
|
||||
"summary": "Remove voucher from cart",
|
||||
"parameters": [{ "$ref": "#/components/parameters/VoucherIdParam" }],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Voucher removed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid id" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/byid/{id}": {
|
||||
"get": {
|
||||
"summary": "Get cart by explicit id",
|
||||
@@ -452,6 +522,53 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/byid/{id}/voucher": {
|
||||
"put": {
|
||||
"summary": "Add voucher (by id variant)",
|
||||
"parameters": [{ "$ref": "#/components/parameters/CartIdParam" }],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/AddVoucherRequest" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Voucher added",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid body" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/byid/{id}/voucher/{voucherId}": {
|
||||
"delete": {
|
||||
"summary": "Remove voucher (by id variant)",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/CartIdParam" },
|
||||
{ "$ref": "#/components/parameters/VoucherIdParam" }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Voucher removed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid ids" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/healthz": {
|
||||
"get": {
|
||||
"summary": "Liveness & capacity probe",
|
||||
@@ -517,6 +634,12 @@
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "integer", "format": "int64", "minimum": 0 }
|
||||
},
|
||||
"VoucherIdParam": {
|
||||
"name": "voucherId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "integer", "format": "int64", "minimum": 0 }
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
@@ -542,7 +665,17 @@
|
||||
"processing": { "type": "boolean" },
|
||||
"paymentInProgress": { "type": "boolean" },
|
||||
"orderReference": { "type": "string" },
|
||||
"paymentStatus": { "type": "string" }
|
||||
"paymentStatus": { "type": "string" },
|
||||
"vouchers": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/components/schemas/Voucher" }
|
||||
},
|
||||
"subscriptionDetails": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/SubscriptionDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["id", "items", "totalPrice", "totalTax", "totalDiscount"]
|
||||
},
|
||||
@@ -670,6 +803,48 @@
|
||||
"pickupPoint": { "$ref": "#/components/schemas/PickupPoint" }
|
||||
},
|
||||
"required": ["provider", "items"]
|
||||
},
|
||||
"AddVoucherRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": { "type": "string" }
|
||||
},
|
||||
"required": ["code"]
|
||||
},
|
||||
"UpsertSubscriptionDetails": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"offeringCode": { "type": "string" },
|
||||
"signingType": { "type": "string" },
|
||||
"data": { "type": "object" }
|
||||
},
|
||||
"required": ["offeringCode", "signingType"]
|
||||
},
|
||||
"Voucher": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": { "type": "string" },
|
||||
"applied": { "type": "boolean" },
|
||||
"rules": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"description": { "type": "string" },
|
||||
"id": { "type": "integer", "format": "int64" },
|
||||
"value": { "type": "integer", "format": "int64" }
|
||||
},
|
||||
"required": ["code", "applied", "rules", "id", "value"]
|
||||
},
|
||||
"SubscriptionDetails": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"offeringCode": { "type": "string" },
|
||||
"signingType": { "type": "string" },
|
||||
"data": { "type": "object" }
|
||||
},
|
||||
"required": ["id"]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
117
cmd/cart/otel.go
Normal file
117
cmd/cart/otel.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||
"go.opentelemetry.io/otel/log/global"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/sdk/log"
|
||||
"go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/trace"
|
||||
)
|
||||
|
||||
// setupOTelSDK bootstraps the OpenTelemetry pipeline.
|
||||
// If it does not return an error, make sure to call shutdown for proper cleanup.
|
||||
func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
|
||||
var shutdownFuncs []func(context.Context) error
|
||||
var err error
|
||||
|
||||
// shutdown calls cleanup functions registered via shutdownFuncs.
|
||||
// The errors from the calls are joined.
|
||||
// Each registered cleanup will be invoked once.
|
||||
shutdown := func(ctx context.Context) error {
|
||||
var err error
|
||||
for _, fn := range shutdownFuncs {
|
||||
err = errors.Join(err, fn(ctx))
|
||||
}
|
||||
shutdownFuncs = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// handleErr calls shutdown for cleanup and makes sure that all errors are returned.
|
||||
handleErr := func(inErr error) {
|
||||
err = errors.Join(inErr, shutdown(ctx))
|
||||
}
|
||||
|
||||
// Set up propagator.
|
||||
prop := newPropagator()
|
||||
otel.SetTextMapPropagator(prop)
|
||||
|
||||
// Set up trace provider.
|
||||
tracerProvider, err := newTracerProvider()
|
||||
if err != nil {
|
||||
handleErr(err)
|
||||
return shutdown, err
|
||||
}
|
||||
shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown)
|
||||
otel.SetTracerProvider(tracerProvider)
|
||||
|
||||
// Set up meter provider.
|
||||
meterProvider, err := newMeterProvider()
|
||||
if err != nil {
|
||||
handleErr(err)
|
||||
return shutdown, err
|
||||
}
|
||||
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
|
||||
otel.SetMeterProvider(meterProvider)
|
||||
|
||||
// Set up logger provider.
|
||||
loggerProvider, err := newLoggerProvider()
|
||||
if err != nil {
|
||||
handleErr(err)
|
||||
return shutdown, err
|
||||
}
|
||||
shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown)
|
||||
global.SetLoggerProvider(loggerProvider)
|
||||
|
||||
return shutdown, err
|
||||
}
|
||||
|
||||
func newPropagator() propagation.TextMapPropagator {
|
||||
return propagation.NewCompositeTextMapPropagator(
|
||||
propagation.TraceContext{},
|
||||
propagation.Baggage{},
|
||||
)
|
||||
}
|
||||
|
||||
func newTracerProvider() (*trace.TracerProvider, error) {
|
||||
traceExporter, err := otlptracegrpc.New(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tracerProvider := trace.NewTracerProvider(
|
||||
trace.WithBatcher(traceExporter,
|
||||
// Default is 5s. Set to 1s for demonstrative purposes.
|
||||
trace.WithBatchTimeout(time.Second)),
|
||||
)
|
||||
return tracerProvider, nil
|
||||
}
|
||||
|
||||
func newMeterProvider() (*metric.MeterProvider, error) {
|
||||
exporter, err := otlpmetricgrpc.New(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
provider := metric.NewMeterProvider(metric.WithReader(metric.NewPeriodicReader(exporter)))
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func newLoggerProvider() (*log.LoggerProvider, error) {
|
||||
logExporter, err := otlploggrpc.New(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
loggerProvider := log.NewLoggerProvider(
|
||||
log.WithProcessor(log.NewBatchProcessor(logExporter)),
|
||||
)
|
||||
return loggerProvider, nil
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -11,31 +12,56 @@ import (
|
||||
"time"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
"git.tornberg.me/go-cart-actor/pkg/voucher"
|
||||
"git.tornberg.me/mats/go-redis-inventory/pkg/inventory"
|
||||
"github.com/gogo/protobuf/proto"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
"google.golang.org/protobuf/types/known/anypb"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
var (
|
||||
grainMutations = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "cart_grain_mutations_total",
|
||||
Help: "The total number of mutations",
|
||||
})
|
||||
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "cart_grain_lookups_total",
|
||||
Help: "The total number of lookups",
|
||||
})
|
||||
)
|
||||
|
||||
type PoolServer struct {
|
||||
actor.GrainPool[*CartGrain]
|
||||
actor.GrainPool[*cart.CartGrain]
|
||||
pod_name string
|
||||
klarnaClient *KlarnaClient
|
||||
inventoryService inventory.InventoryService
|
||||
}
|
||||
|
||||
func NewPoolServer(pool actor.GrainPool[*CartGrain], pod_name string, klarnaClient *KlarnaClient) *PoolServer {
|
||||
func NewPoolServer(pool actor.GrainPool[*cart.CartGrain], pod_name string, klarnaClient *KlarnaClient, inventoryService inventory.InventoryService) *PoolServer {
|
||||
return &PoolServer{
|
||||
GrainPool: pool,
|
||||
pod_name: pod_name,
|
||||
klarnaClient: klarnaClient,
|
||||
inventoryService: inventoryService,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PoolServer) ApplyLocal(id CartId, mutation ...proto.Message) (*actor.MutationResult[*CartGrain], error) {
|
||||
return s.Apply(uint64(id), mutation...)
|
||||
func (s *PoolServer) ApplyLocal(ctx context.Context, id cart.CartId, mutation ...proto.Message) (*actor.MutationResult[*cart.CartGrain], error) {
|
||||
return s.Apply(ctx, uint64(id), mutation...)
|
||||
}
|
||||
|
||||
func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
grain, err := s.Get(uint64(id))
|
||||
func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
grain, err := s.Get(r.Context(), uint64(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -43,16 +69,17 @@ func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id C
|
||||
return s.WriteResult(w, grain)
|
||||
}
|
||||
|
||||
func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
sku := r.PathValue("sku")
|
||||
msg, err := GetItemAddMessage(sku, 1, getCountryFromHost(r.Host), nil)
|
||||
msg, err := GetItemAddMessage(r.Context(), sku, 1, getCountryFromHost(r.Host), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := s.ApplyLocal(id, msg)
|
||||
data, err := s.ApplyLocal(r.Context(), id, msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
grainMutations.Add(float64(len(data.Mutations)))
|
||||
return s.WriteResult(w, data)
|
||||
}
|
||||
|
||||
@@ -73,14 +100,14 @@ func (s *PoolServer) WriteResult(w http.ResponseWriter, result any) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PoolServer) DeleteItemHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
func (s *PoolServer) DeleteItemHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
|
||||
itemIdString := r.PathValue("itemId")
|
||||
itemId, err := strconv.ParseInt(itemIdString, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := s.ApplyLocal(id, &messages.RemoveItem{Id: uint32(itemId)})
|
||||
data, err := s.ApplyLocal(r.Context(), id, &messages.RemoveItem{Id: uint32(itemId)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -93,14 +120,14 @@ type SetDeliveryRequest struct {
|
||||
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
|
||||
}
|
||||
|
||||
func (s *PoolServer) SetDeliveryHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
func (s *PoolServer) SetDeliveryHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
|
||||
delivery := SetDeliveryRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(&delivery)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := s.ApplyLocal(id, &messages.SetDelivery{
|
||||
data, err := s.ApplyLocal(r.Context(), id, &messages.SetDelivery{
|
||||
Provider: delivery.Provider,
|
||||
Items: delivery.Items,
|
||||
PickupPoint: delivery.PickupPoint,
|
||||
@@ -111,7 +138,7 @@ func (s *PoolServer) SetDeliveryHandler(w http.ResponseWriter, r *http.Request,
|
||||
return s.WriteResult(w, data)
|
||||
}
|
||||
|
||||
func (s *PoolServer) SetPickupPointHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
func (s *PoolServer) SetPickupPointHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
|
||||
deliveryIdString := r.PathValue("deliveryId")
|
||||
deliveryId, err := strconv.ParseInt(deliveryIdString, 10, 64)
|
||||
@@ -123,7 +150,7 @@ func (s *PoolServer) SetPickupPointHandler(w http.ResponseWriter, r *http.Reques
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.ApplyLocal(id, &messages.SetPickupPoint{
|
||||
reply, err := s.ApplyLocal(r.Context(), id, &messages.SetPickupPoint{
|
||||
DeliveryId: uint32(deliveryId),
|
||||
Id: pickupPoint.Id,
|
||||
Name: pickupPoint.Name,
|
||||
@@ -138,27 +165,27 @@ func (s *PoolServer) SetPickupPointHandler(w http.ResponseWriter, r *http.Reques
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
func (s *PoolServer) RemoveDeliveryHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
func (s *PoolServer) RemoveDeliveryHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
|
||||
deliveryIdString := r.PathValue("deliveryId")
|
||||
deliveryId, err := strconv.Atoi(deliveryIdString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.ApplyLocal(id, &messages.RemoveDelivery{Id: uint32(deliveryId)})
|
||||
reply, err := s.ApplyLocal(r.Context(), 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 {
|
||||
func (s *PoolServer) QuantityChangeHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
changeQuantity := messages.ChangeQuantity{}
|
||||
err := json.NewDecoder(r.Body).Decode(&changeQuantity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.ApplyLocal(id, &changeQuantity)
|
||||
reply, err := s.ApplyLocal(r.Context(), id, &changeQuantity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -176,14 +203,14 @@ type SetCartItems struct {
|
||||
Items []Item `json:"items"`
|
||||
}
|
||||
|
||||
func getMultipleAddMessages(items []Item, country string) []proto.Message {
|
||||
func getMultipleAddMessages(ctx context.Context, 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)
|
||||
msg, err := GetItemAddMessage(ctx, itm.Sku, itm.Quantity, country, itm.StoreId)
|
||||
if err != nil {
|
||||
log.Printf("error adding item %s: %v", itm.Sku, err)
|
||||
return
|
||||
@@ -197,7 +224,7 @@ func getMultipleAddMessages(items []Item, country string) []proto.Message {
|
||||
return msgs
|
||||
}
|
||||
|
||||
func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
setCartItems := SetCartItems{}
|
||||
err := json.NewDecoder(r.Body).Decode(&setCartItems)
|
||||
if err != nil {
|
||||
@@ -206,23 +233,23 @@ func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request,
|
||||
|
||||
msgs := make([]proto.Message, 0, len(setCartItems.Items)+1)
|
||||
msgs = append(msgs, &messages.ClearCartRequest{})
|
||||
msgs = append(msgs, getMultipleAddMessages(setCartItems.Items, setCartItems.Country)...)
|
||||
msgs = append(msgs, getMultipleAddMessages(r.Context(), setCartItems.Items, setCartItems.Country)...)
|
||||
|
||||
reply, err := s.ApplyLocal(id, msgs...)
|
||||
reply, err := s.ApplyLocal(r.Context(), 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 {
|
||||
func (s *PoolServer) AddMultipleItemHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
setCartItems := SetCartItems{}
|
||||
err := json.NewDecoder(r.Body).Decode(&setCartItems)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reply, err := s.ApplyLocal(id, getMultipleAddMessages(setCartItems.Items, setCartItems.Country)...)
|
||||
reply, err := s.ApplyLocal(r.Context(), id, getMultipleAddMessages(r.Context(), setCartItems.Items, setCartItems.Country)...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -236,17 +263,17 @@ type AddRequest struct {
|
||||
StoreId *string `json:"storeId"`
|
||||
}
|
||||
|
||||
func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
addRequest := AddRequest{Quantity: 1}
|
||||
err := json.NewDecoder(r.Body).Decode(&addRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg, err := GetItemAddMessage(addRequest.Sku, int(addRequest.Quantity), addRequest.Country, addRequest.StoreId)
|
||||
msg, err := GetItemAddMessage(r.Context(), addRequest.Sku, int(addRequest.Quantity), addRequest.Country, addRequest.StoreId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.ApplyLocal(id, msg)
|
||||
reply, err := s.ApplyLocal(r.Context(), id, msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -287,24 +314,61 @@ func getLocale(country string) string {
|
||||
return "sv-se"
|
||||
}
|
||||
|
||||
func (s *PoolServer) CreateOrUpdateCheckout(host string, id CartId) (*CheckoutOrder, error) {
|
||||
func getLocationId(item *cart.CartItem) inventory.LocationID {
|
||||
if item.StoreId == nil || *item.StoreId == "" {
|
||||
return "se"
|
||||
}
|
||||
return inventory.LocationID(*item.StoreId)
|
||||
}
|
||||
|
||||
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
|
||||
var requests []inventory.ReserveRequest
|
||||
for _, item := range items {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
requests = append(requests, inventory.ReserveRequest{
|
||||
SKU: inventory.SKU(item.Sku),
|
||||
LocationID: getLocationId(item),
|
||||
Quantity: uint32(item.Quantity),
|
||||
})
|
||||
}
|
||||
return requests
|
||||
}
|
||||
|
||||
func getOriginalHost(r *http.Request) string {
|
||||
proxyHost := r.Header.Get("X-Forwarded-Host")
|
||||
if proxyHost != "" {
|
||||
return proxyHost
|
||||
}
|
||||
return r.Host
|
||||
}
|
||||
|
||||
func (s *PoolServer) CreateOrUpdateCheckout(r *http.Request, id cart.CartId) (*CheckoutOrder, error) {
|
||||
host := getOriginalHost(r)
|
||||
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))
|
||||
grain, err := s.Get(r.Context(), uint64(id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.inventoryService != nil {
|
||||
inventoryRequests := getInventoryRequests(grain.Items)
|
||||
failingRequest, err := s.inventoryService.ReservationCheck(r.Context(), inventoryRequests...)
|
||||
if err != nil {
|
||||
logger.WarnContext(r.Context(), "inventory check failed", string(failingRequest.SKU), string(failingRequest.LocationID))
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Build pure checkout payload
|
||||
payload, _, err := BuildCheckoutOrderPayload(grain, meta)
|
||||
@@ -313,15 +377,15 @@ func (s *PoolServer) CreateOrUpdateCheckout(host string, id CartId) (*CheckoutOr
|
||||
}
|
||||
|
||||
if grain.OrderReference != "" {
|
||||
return s.klarnaClient.UpdateOrder(grain.OrderReference, bytes.NewReader(payload))
|
||||
return s.klarnaClient.UpdateOrder(r.Context(), grain.OrderReference, bytes.NewReader(payload))
|
||||
} else {
|
||||
return s.klarnaClient.CreateOrder(bytes.NewReader(payload))
|
||||
return s.klarnaClient.CreateOrder(r.Context(), bytes.NewReader(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PoolServer) ApplyCheckoutStarted(klarnaOrder *CheckoutOrder, id CartId) (*actor.MutationResult[*CartGrain], error) {
|
||||
func (s *PoolServer) ApplyCheckoutStarted(ctx context.Context, klarnaOrder *CheckoutOrder, id cart.CartId) (*actor.MutationResult[*cart.CartGrain], error) {
|
||||
// Persist initialization state via mutation (best-effort)
|
||||
return s.ApplyLocal(id, &messages.InitializeCheckout{
|
||||
return s.ApplyLocal(ctx, id, &messages.InitializeCheckout{
|
||||
OrderId: klarnaOrder.ID,
|
||||
Status: klarnaOrder.Status,
|
||||
PaymentInProgress: true,
|
||||
@@ -341,12 +405,12 @@ func (s *PoolServer) ApplyCheckoutStarted(klarnaOrder *CheckoutOrder, id CartId)
|
||||
// }
|
||||
//
|
||||
|
||||
func CookieCartIdHandler(fn func(cartId CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
|
||||
func CookieCartIdHandler(fn func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var id CartId
|
||||
var id cart.CartId
|
||||
cookie, err := r.Cookie("cartid")
|
||||
if err != nil || cookie.Value == "" {
|
||||
id = MustNewCartId()
|
||||
id = cart.MustNewCartId()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "cartid",
|
||||
Value: id.String(),
|
||||
@@ -358,9 +422,9 @@ func CookieCartIdHandler(fn func(cartId CartId, w http.ResponseWriter, r *http.R
|
||||
})
|
||||
w.Header().Set("Set-Cart-Id", id.String())
|
||||
} else {
|
||||
parsed, ok := ParseCartId(cookie.Value)
|
||||
parsed, ok := cart.ParseCartId(cookie.Value)
|
||||
if !ok {
|
||||
id = MustNewCartId()
|
||||
id = cart.MustNewCartId()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "cartid",
|
||||
Value: id.String(),
|
||||
@@ -388,7 +452,7 @@ func CookieCartIdHandler(fn func(cartId CartId, w http.ResponseWriter, r *http.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 {
|
||||
func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||
// Clear cart cookie (breaking change: do not issue a new legacy id here)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "cartid",
|
||||
@@ -403,17 +467,17 @@ func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, ca
|
||||
return nil
|
||||
}
|
||||
|
||||
func CartIdHandler(fn func(cartId CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
|
||||
func CartIdHandler(fn func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var id CartId
|
||||
var id cart.CartId
|
||||
raw := r.PathValue("id")
|
||||
// If no id supplied, generate a new one
|
||||
if raw == "" {
|
||||
id := MustNewCartId()
|
||||
id := cart.MustNewCartId()
|
||||
w.Header().Set("Set-Cart-Id", id.String())
|
||||
} else {
|
||||
// Parse base62 cart id
|
||||
if parsedId, ok := ParseCartId(raw); !ok {
|
||||
if parsedId, ok := cart.ParseCartId(raw); !ok {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("cart id is invalid"))
|
||||
return
|
||||
@@ -431,35 +495,71 @@ func CartIdHandler(fn func(cartId CartId, w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error) func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error {
|
||||
return func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error {
|
||||
if ownerHost, ok := s.OwnerHost(uint64(cartId)); ok {
|
||||
ctx, span := tracer.Start(r.Context(), "proxy")
|
||||
defer span.End()
|
||||
span.SetAttributes(attribute.String("cartid", cartId.String()))
|
||||
hostAttr := attribute.String("other host", ownerHost.Name())
|
||||
span.SetAttributes(hostAttr)
|
||||
logger.InfoContext(ctx, "cart proxyed", "result", ownerHost.Name())
|
||||
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
|
||||
handled, err := ownerHost.Proxy(uint64(cartId), w, r)
|
||||
|
||||
grainLookups.Inc()
|
||||
if err == nil && handled {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
_, span := tracer.Start(r.Context(), "own")
|
||||
span.SetAttributes(attribute.String("cartid", cartId.String()))
|
||||
defer span.End()
|
||||
return fn(w, r, cartId)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
tracer = otel.Tracer(name)
|
||||
|
||||
meter = otel.Meter(name)
|
||||
logger = otelslog.NewLogger(name)
|
||||
proxyCalls metric.Int64Counter
|
||||
|
||||
// rollCnt metric.Int64Counter
|
||||
)
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
proxyCalls, err = meter.Int64Counter("proxy.calls",
|
||||
metric.WithDescription("Number of proxy calls"),
|
||||
metric.WithUnit("{calls}"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
type AddVoucherRequest struct {
|
||||
VoucherCode string `json:"code"`
|
||||
}
|
||||
|
||||
func (s *PoolServer) AddVoucherHandler(w http.ResponseWriter, r *http.Request, cartId CartId) error {
|
||||
func (s *PoolServer) AddVoucherHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||
data := &AddVoucherRequest{}
|
||||
json.NewDecoder(r.Body).Decode(data)
|
||||
v := voucher.Service{}
|
||||
msg, err := v.GetVoucher(data.VoucherCode)
|
||||
if err != nil {
|
||||
s.ApplyLocal(r.Context(), cartId, &messages.PreConditionFailed{
|
||||
Operation: "AddVoucher",
|
||||
Error: err.Error(),
|
||||
})
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return err
|
||||
}
|
||||
reply, err := s.ApplyLocal(cartId, msg)
|
||||
reply, err := s.ApplyLocal(r.Context(), cartId, msg)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
@@ -469,7 +569,61 @@ func (s *PoolServer) AddVoucherHandler(w http.ResponseWriter, r *http.Request, c
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request, cartId CartId) error {
|
||||
type SubscriptionDetailsRequest struct {
|
||||
Id *string `json:"id,omitempty"`
|
||||
OfferingCode string `json:"offeringCode,omitempty"`
|
||||
SigningType string `json:"signingType,omitempty"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func (sd *SubscriptionDetailsRequest) ToMessage() *messages.UpsertSubscriptionDetails {
|
||||
return &messages.UpsertSubscriptionDetails{
|
||||
Id: sd.Id,
|
||||
OfferingCode: sd.OfferingCode,
|
||||
SigningType: sd.SigningType,
|
||||
Data: &anypb.Any{Value: sd.Data},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PoolServer) SubscriptionDetailsHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||
data := &SubscriptionDetailsRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(data)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return err
|
||||
}
|
||||
reply, err := s.ApplyLocal(r.Context(), cartId, data.ToMessage())
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return err
|
||||
}
|
||||
s.WriteResult(w, reply)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PoolServer) CheckoutHandler(fn func(order *CheckoutOrder, w http.ResponseWriter) error) func(w http.ResponseWriter, r *http.Request) {
|
||||
return CookieCartIdHandler(s.ProxyHandler(func(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||
orderId := r.URL.Query().Get("order_id")
|
||||
if orderId == "" {
|
||||
order, err := s.CreateOrUpdateCheckout(r, cartId)
|
||||
if err != nil {
|
||||
logger.Error("unable to create klarna session", "error", err)
|
||||
return err
|
||||
}
|
||||
s.ApplyCheckoutStarted(r.Context(), order, cartId)
|
||||
return fn(order, w)
|
||||
}
|
||||
order, err := s.klarnaClient.GetOrder(r.Context(), orderId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fn(order, w)
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||
|
||||
idStr := r.PathValue("voucherId")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
@@ -478,7 +632,7 @@ func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request
|
||||
w.Write([]byte(err.Error()))
|
||||
return err
|
||||
}
|
||||
reply, err := s.ApplyLocal(cartId, &messages.RemoveVoucher{Id: uint32(id)})
|
||||
reply, err := s.ApplyLocal(r.Context(), cartId, &messages.RemoveVoucher{Id: uint32(id)})
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
@@ -488,45 +642,58 @@ func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PoolServer) Serve() *http.ServeMux {
|
||||
func (s *PoolServer) Serve(mux *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("OPTIONS /cart", func(w http.ResponseWriter, r *http.Request) {
|
||||
// w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
// w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE")
|
||||
// w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
// w.WriteHeader(http.StatusOK)
|
||||
// })
|
||||
|
||||
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)))
|
||||
handleFunc := func(pattern string, handlerFunc func(http.ResponseWriter, *http.Request)) {
|
||||
attr := attribute.String("http.route", pattern)
|
||||
mux.HandleFunc(pattern, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
span := trace.SpanFromContext(r.Context())
|
||||
span.SetName(pattern)
|
||||
span.SetAttributes(attr)
|
||||
|
||||
//mux.HandleFunc("GET /checkout", CookieCartIdHandler(s.ProxyHandler(s.HandleCheckout)))
|
||||
//mux.HandleFunc("GET /confirmation/{orderId}", CookieCartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
|
||||
labeler, _ := otelhttp.LabelerFromContext(r.Context())
|
||||
labeler.Add(attr)
|
||||
|
||||
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)))
|
||||
handlerFunc(w, r)
|
||||
}))
|
||||
}
|
||||
|
||||
handleFunc("GET /cart", CookieCartIdHandler(s.ProxyHandler(s.GetCartHandler)))
|
||||
handleFunc("GET /cart/add/{sku}", CookieCartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler)))
|
||||
handleFunc("POST /cart/add", CookieCartIdHandler(s.ProxyHandler(s.AddMultipleItemHandler)))
|
||||
handleFunc("POST /cart", CookieCartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
|
||||
handleFunc("POST /cart/set", CookieCartIdHandler(s.ProxyHandler(s.SetCartItemsHandler)))
|
||||
handleFunc("DELETE /cart/{itemId}", CookieCartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
|
||||
handleFunc("PUT /cart", CookieCartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
|
||||
handleFunc("DELETE /cart", CookieCartIdHandler(s.ProxyHandler(s.RemoveCartCookie)))
|
||||
handleFunc("POST /cart/delivery", CookieCartIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
|
||||
handleFunc("DELETE /cart/delivery/{deliveryId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
|
||||
handleFunc("PUT /cart/delivery/{deliveryId}/pickupPoint", CookieCartIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
|
||||
handleFunc("PUT /cart/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
|
||||
handleFunc("PUT /cart/subscription-details", CookieCartIdHandler(s.ProxyHandler(s.SubscriptionDetailsHandler)))
|
||||
handleFunc("DELETE /cart/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
|
||||
|
||||
//mux.HandleFunc("GET /cart/checkout", CookieCartIdHandler(s.ProxyHandler(s.HandleCheckout)))
|
||||
//mux.HandleFunc("GET /cart/confirmation/{orderId}", CookieCartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
|
||||
|
||||
handleFunc("GET /cart/byid/{id}", CartIdHandler(s.ProxyHandler(s.GetCartHandler)))
|
||||
handleFunc("GET /cart/byid/{id}/add/{sku}", CartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler)))
|
||||
handleFunc("POST /cart/byid/{id}", CartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
|
||||
handleFunc("DELETE /cart/byid/{id}/{itemId}", CartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
|
||||
handleFunc("PUT /cart/byid/{id}", CartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
|
||||
handleFunc("POST /cart/byid/{id}/delivery", CartIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
|
||||
handleFunc("DELETE /cart/byid/{id}/delivery/{deliveryId}", CartIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
|
||||
handleFunc("PUT /cart/byid/{id}/delivery/{deliveryId}/pickupPoint", CartIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
|
||||
handleFunc("PUT /cart/byid/{id}/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
|
||||
handleFunc("DELETE /cart/byid/{id}/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
|
||||
//mux.HandleFunc("GET /cart/byid/{id}/checkout", CartIdHandler(s.ProxyHandler(s.HandleCheckout)))
|
||||
//mux.HandleFunc("GET /cart/byid/{id}/confirmation", CartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
"github.com/matst80/slask-finder/pkg/index"
|
||||
)
|
||||
@@ -25,9 +25,16 @@ func getBaseUrl(country string) string {
|
||||
return "http://localhost:8082"
|
||||
}
|
||||
|
||||
func FetchItem(sku string, country string) (*index.DataItem, error) {
|
||||
func FetchItem(ctx context.Context, sku string, country string) (*index.DataItem, error) {
|
||||
baseUrl := getBaseUrl(country)
|
||||
res, err := http.Get(fmt.Sprintf("%s/api/by-sku/%s", baseUrl, sku))
|
||||
req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/by-sku/%s", baseUrl, sku), nil)
|
||||
innerCtx, span := tracer.Start(ctx, fmt.Sprintf("fetching data for %s", sku))
|
||||
defer span.End()
|
||||
req = req.WithContext(innerCtx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -37,36 +44,46 @@ func FetchItem(sku string, country string) (*index.DataItem, error) {
|
||||
return &item, err
|
||||
}
|
||||
|
||||
func GetItemAddMessage(sku string, qty int, country string, storeId *string) (*messages.AddItem, error) {
|
||||
item, err := FetchItem(sku, country)
|
||||
func GetItemAddMessage(ctx context.Context, sku string, qty int, country string, storeId *string) (*messages.AddItem, error) {
|
||||
item, err := FetchItem(ctx, sku, country)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ToItemAddMessage(item, storeId, qty, country), nil
|
||||
return ToItemAddMessage(item, storeId, qty, country)
|
||||
}
|
||||
|
||||
func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country string) *messages.AddItem {
|
||||
func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country string) (*messages.AddItem, error) {
|
||||
orgPrice, _ := getInt(item.GetNumberFieldValue(5)) // getInt(item.Fields[5])
|
||||
|
||||
price, err := getInt(item.GetNumberFieldValue(4)) //Fields[4]
|
||||
if err != nil {
|
||||
return nil
|
||||
return nil, err
|
||||
}
|
||||
stk := item.GetStock()
|
||||
stock := cart.StockStatus(0)
|
||||
|
||||
if storeId == nil {
|
||||
centralStock, ok := stk[country]
|
||||
if ok {
|
||||
if !item.Buyable {
|
||||
return nil, fmt.Errorf("item not available")
|
||||
}
|
||||
|
||||
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)
|
||||
if centralStock == 0 && item.SaleStatus == "TBD" {
|
||||
return nil, fmt.Errorf("no items available")
|
||||
}
|
||||
stock = cart.StockStatus(centralStock)
|
||||
}
|
||||
|
||||
} else {
|
||||
storeStock, ok := item.Stock.GetStock()[*storeId]
|
||||
if ok {
|
||||
stock = StockStatus(storeStock)
|
||||
if !item.BuyableInStore {
|
||||
return nil, fmt.Errorf("item not available in store")
|
||||
}
|
||||
storeStock, ok := stk[*storeId]
|
||||
if ok {
|
||||
stock = cart.StockStatus(storeStock)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
articleType, _ := item.GetStringFieldValue(1) //.Fields[1].(string)
|
||||
@@ -108,7 +125,8 @@ func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country st
|
||||
Country: country,
|
||||
Outlet: outlet,
|
||||
StoreId: storeId,
|
||||
}
|
||||
SaleStatus: item.SaleStatus,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getTax(articleType string) int32 {
|
||||
@@ -119,3 +137,10 @@ func getTax(articleType string) int32 {
|
||||
return 2500
|
||||
}
|
||||
}
|
||||
|
||||
func getInt(data float64, ok bool) (int, error) {
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("invalid type")
|
||||
}
|
||||
return int(data), nil
|
||||
}
|
||||
|
||||
143
cmd/inventory/main.go
Normal file
143
cmd/inventory/main.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.tornberg.me/mats/go-redis-inventory/pkg/inventory"
|
||||
"github.com/matst80/slask-finder/pkg/index"
|
||||
"github.com/matst80/slask-finder/pkg/messaging"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/redis/go-redis/v9/maintnotifications"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
service *inventory.RedisInventoryService
|
||||
}
|
||||
|
||||
func (srv *Server) livezHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
}
|
||||
|
||||
func (srv *Server) readyzHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
}
|
||||
|
||||
func (srv *Server) getInventoryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse path: /inventory/{sku}/{location}
|
||||
path := r.URL.Path
|
||||
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||
if len(parts) != 3 || parts[0] != "inventory" {
|
||||
http.Error(w, "Invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
sku := inventory.SKU(parts[1])
|
||||
locationID := inventory.LocationID(parts[2])
|
||||
|
||||
quantity, err := srv.service.GetInventory(r.Context(), sku, locationID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]int64{"quantity": quantity}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
var country = "se"
|
||||
var redisAddress = "10.10.3.18:6379"
|
||||
var redisPassword = "slaskredis"
|
||||
|
||||
func init() {
|
||||
// Override redis config from environment variables if set
|
||||
if addr, ok := os.LookupEnv("REDIS_ADDRESS"); ok {
|
||||
redisAddress = addr
|
||||
}
|
||||
if password, ok := os.LookupEnv("REDIS_PASSWORD"); ok {
|
||||
redisPassword = password
|
||||
}
|
||||
if ctry, ok := os.LookupEnv("COUNTRY"); ok {
|
||||
country = ctry
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
var ctx = context.Background()
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: redisAddress,
|
||||
Password: redisPassword, // no password set
|
||||
DB: 0, // use default DB
|
||||
MaintNotificationsConfig: &maintnotifications.Config{
|
||||
Mode: maintnotifications.ModeDisabled,
|
||||
},
|
||||
})
|
||||
s, err := inventory.NewRedisInventoryService(rdb)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to connect to inventory redis: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
server := &Server{service: s}
|
||||
|
||||
// Set up HTTP routes
|
||||
http.HandleFunc("/livez", server.livezHandler)
|
||||
http.HandleFunc("/readyz", server.readyzHandler)
|
||||
http.HandleFunc("/inventory/", server.getInventoryHandler)
|
||||
|
||||
stockhandler := &StockHandler{
|
||||
MainStockLocationID: inventory.LocationID(country),
|
||||
rdb: rdb,
|
||||
ctx: ctx,
|
||||
svc: *s,
|
||||
}
|
||||
|
||||
amqpUrl, ok := os.LookupEnv("RABBIT_HOST")
|
||||
if ok {
|
||||
log.Printf("Connecting to rabbitmq")
|
||||
conn, err := amqp.DialConfig(amqpUrl, amqp.Config{
|
||||
Properties: amqp.NewConnectionProperties(),
|
||||
})
|
||||
//a.conn = conn
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to RabbitMQ: %v", err)
|
||||
}
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open a channel: %v", err)
|
||||
}
|
||||
// items listener
|
||||
err = messaging.ListenToTopic(ch, country, "item_added", func(d amqp.Delivery) error {
|
||||
var items []*index.DataItem
|
||||
err := json.Unmarshal(d.Body, &items)
|
||||
if err == nil {
|
||||
log.Printf("Got upserts %d, message count %d", len(items), d.MessageCount)
|
||||
wg := &sync.WaitGroup{}
|
||||
for _, item := range items {
|
||||
stockhandler.HandleItem(item, wg)
|
||||
}
|
||||
wg.Wait()
|
||||
log.Print("Batch done...")
|
||||
} else {
|
||||
log.Printf("Failed to unmarshal upsert message %v", err)
|
||||
}
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to listen to item_added topic: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Start HTTP server
|
||||
log.Println("Starting HTTP server on :8080")
|
||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||
}
|
||||
49
cmd/inventory/stockhandler.go
Normal file
49
cmd/inventory/stockhandler.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.tornberg.me/mats/go-redis-inventory/pkg/inventory"
|
||||
"github.com/matst80/slask-finder/pkg/types"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type StockHandler struct {
|
||||
rdb *redis.Client
|
||||
ctx context.Context
|
||||
svc inventory.RedisInventoryService
|
||||
MainStockLocationID inventory.LocationID
|
||||
}
|
||||
|
||||
func (s *StockHandler) HandleItem(item types.Item, wg *sync.WaitGroup) {
|
||||
wg.Go(func() {
|
||||
ctx := s.ctx
|
||||
pipe := s.rdb.Pipeline()
|
||||
centralStockString, ok := item.GetStringFieldValue(3)
|
||||
if !ok {
|
||||
centralStockString = "0"
|
||||
}
|
||||
centralStockString = strings.Replace(centralStockString, "+", "", -1)
|
||||
centralStockString = strings.Replace(centralStockString, "<", "", -1)
|
||||
centralStockString = strings.Replace(centralStockString, ">", "", -1)
|
||||
centralStock, err := strconv.ParseInt(centralStockString, 10, 64)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("unable to parse central stock for item %s: %v", item.GetSku(), err)
|
||||
centralStock = 0
|
||||
} else {
|
||||
s.svc.UpdateInventory(ctx, pipe, inventory.SKU(item.GetSku()), s.MainStockLocationID, int64(centralStock))
|
||||
}
|
||||
for id, value := range item.GetStock() {
|
||||
s.svc.UpdateInventory(ctx, pipe, inventory.SKU(item.GetSku()), inventory.LocationID(id), int64(value))
|
||||
}
|
||||
_, err = pipe.Exec(ctx)
|
||||
if err != nil {
|
||||
log.Printf("unable to update stock: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
BIN
data/1.prot
BIN
data/1.prot
Binary file not shown.
BIN
data/4.prot
BIN
data/4.prot
Binary file not shown.
BIN
data/5.prot
BIN
data/5.prot
Binary file not shown.
BIN
data/state.gob
BIN
data/state.gob
Binary file not shown.
Binary file not shown.
@@ -9,6 +9,92 @@ type: Opaque
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
app: cart-backoffice
|
||||
arch: amd64
|
||||
name: cart-backoffice-x86
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: cart-backoffice
|
||||
arch: amd64
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: cart-backoffice
|
||||
arch: amd64
|
||||
spec:
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/arch
|
||||
operator: NotIn
|
||||
values:
|
||||
- arm64
|
||||
volumes:
|
||||
- name: data
|
||||
nfs:
|
||||
path: /i-data/7a8af061/nfs/cart-actor
|
||||
server: 10.10.1.10
|
||||
serviceAccountName: default
|
||||
containers:
|
||||
- image: registry.k6n.net/go-cart-actor-amd64:latest
|
||||
name: cart-actor-amd64
|
||||
imagePullPolicy: Always
|
||||
command: ["/go-cart-backoffice"]
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command: ["sleep", "15"]
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: web
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /livez
|
||||
port: web
|
||||
failureThreshold: 1
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: web
|
||||
failureThreshold: 2
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 30
|
||||
volumeMounts:
|
||||
- mountPath: "/data"
|
||||
name: data
|
||||
resources:
|
||||
limits:
|
||||
memory: "768Mi"
|
||||
requests:
|
||||
memory: "70Mi"
|
||||
cpu: "1200m"
|
||||
env:
|
||||
- name: TZ
|
||||
value: "Europe/Stockholm"
|
||||
- name: KLARNA_API_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: klarna-api-credentials
|
||||
key: username
|
||||
- name: KLARNA_API_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: klarna-api-credentials
|
||||
key: password
|
||||
- name: AMQP_URL
|
||||
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
|
||||
# - name: BASE_URL
|
||||
# value: "https://s10n-no.tornberg.me"
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
app: cart-actor
|
||||
@@ -41,11 +127,9 @@ spec:
|
||||
nfs:
|
||||
path: /i-data/7a8af061/nfs/cart-actor
|
||||
server: 10.10.1.10
|
||||
imagePullSecrets:
|
||||
- name: regcred
|
||||
serviceAccountName: default
|
||||
containers:
|
||||
- image: registry.knatofs.se/go-cart-actor-amd64:latest
|
||||
- image: registry.k6n.net/go-cart-actor-amd64:latest
|
||||
name: cart-actor-amd64
|
||||
imagePullPolicy: Always
|
||||
lifecycle:
|
||||
@@ -55,6 +139,8 @@ spec:
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: web
|
||||
- containerPort: 8081
|
||||
name: debug
|
||||
- containerPort: 1337
|
||||
name: rpc
|
||||
livenessProbe:
|
||||
@@ -62,14 +148,14 @@ spec:
|
||||
path: /livez
|
||||
port: web
|
||||
failureThreshold: 1
|
||||
periodSeconds: 10
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: web
|
||||
failureThreshold: 2
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 10
|
||||
periodSeconds: 50
|
||||
volumeMounts:
|
||||
- mountPath: "/data"
|
||||
name: data
|
||||
@@ -87,6 +173,14 @@ spec:
|
||||
secretKeyRef:
|
||||
name: klarna-api-credentials
|
||||
key: username
|
||||
- name: REDIS_ADDRESS
|
||||
value: "10.10.3.18:6379"
|
||||
- name: REDIS_PASSWORD
|
||||
value: "slaskredis"
|
||||
- name: OTEL_RESOURCE_ATTRIBUTES
|
||||
value: "service.name=cart,service.version=0.1.2"
|
||||
- name: OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
value: "http://otel-debug-service.monitoring:4317"
|
||||
- name: KLARNA_API_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
@@ -143,11 +237,9 @@ spec:
|
||||
nfs:
|
||||
path: /i-data/7a8af061/nfs/cart-actor
|
||||
server: 10.10.1.10
|
||||
imagePullSecrets:
|
||||
- name: regcred
|
||||
serviceAccountName: default
|
||||
containers:
|
||||
- image: registry.knatofs.se/go-cart-actor:latest
|
||||
- image: registry.k6n.net/go-cart-actor:latest
|
||||
name: cart-actor-arm64
|
||||
imagePullPolicy: Always
|
||||
lifecycle:
|
||||
@@ -157,6 +249,8 @@ spec:
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: web
|
||||
- containerPort: 8081
|
||||
name: debug
|
||||
- containerPort: 1337
|
||||
name: rpc
|
||||
livenessProbe:
|
||||
@@ -164,14 +258,14 @@ spec:
|
||||
path: /livez
|
||||
port: web
|
||||
failureThreshold: 1
|
||||
periodSeconds: 10
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: web
|
||||
failureThreshold: 2
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 10
|
||||
periodSeconds: 30
|
||||
volumeMounts:
|
||||
- mountPath: "/data"
|
||||
name: data
|
||||
@@ -184,6 +278,14 @@ spec:
|
||||
env:
|
||||
- name: TZ
|
||||
value: "Europe/Stockholm"
|
||||
- name: REDIS_ADDRESS
|
||||
value: "redis.home:6379"
|
||||
- name: REDIS_PASSWORD
|
||||
value: "slaskredis"
|
||||
- name: OTEL_RESOURCE_ATTRIBUTES
|
||||
value: "service.name=cart,service.version=0.1.2"
|
||||
- name: OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
value: "http://otel-debug-service.monitoring:4317"
|
||||
- name: KLARNA_API_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
@@ -212,7 +314,7 @@ apiVersion: v1
|
||||
metadata:
|
||||
name: cart-actor
|
||||
annotations:
|
||||
prometheus.io/port: "8080"
|
||||
prometheus.io/port: "8081"
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/path: "/metrics"
|
||||
spec:
|
||||
@@ -222,6 +324,17 @@ spec:
|
||||
- name: web
|
||||
port: 8080
|
||||
---
|
||||
kind: Service
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: cart-backoffice
|
||||
spec:
|
||||
selector:
|
||||
app: cart-backoffice
|
||||
ports:
|
||||
- name: web
|
||||
port: 8080
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
@@ -250,3 +363,98 @@ spec:
|
||||
name: cart-actor
|
||||
port:
|
||||
number: 8080
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: cart-backend-ingress
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- slask-cart.tornberg.me
|
||||
secretName: cart-backoffice-actor-tls-secret
|
||||
rules:
|
||||
- host: slask-cart.tornberg.me
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: cart-backoffice
|
||||
port:
|
||||
number: 8080
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
app: cart-inventory
|
||||
arch: amd64
|
||||
name: cart-inventory-x86
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: cart-inventory
|
||||
arch: amd64
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: cart-inventory
|
||||
arch: amd64
|
||||
spec:
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/arch
|
||||
operator: NotIn
|
||||
values:
|
||||
- arm64
|
||||
serviceAccountName: default
|
||||
containers:
|
||||
- image: registry.k6n.net/go-cart-actor-amd64:latest
|
||||
name: cart-inventory-amd64
|
||||
imagePullPolicy: Always
|
||||
command: ["/go-cart-inventory"]
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command: ["sleep", "15"]
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: web
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /livez
|
||||
port: web
|
||||
failureThreshold: 1
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: web
|
||||
failureThreshold: 2
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 30
|
||||
resources:
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
cpu: "500m"
|
||||
requests:
|
||||
memory: "50Mi"
|
||||
cpu: "500m"
|
||||
env:
|
||||
- name: TZ
|
||||
value: "Europe/Stockholm"
|
||||
- name: RABBIT_HOST
|
||||
value: amqp://admin:12bananer@rabbitmq.s10n:5672/
|
||||
- name: REDIS_ADDRESS
|
||||
value: "redis.home:6379"
|
||||
- name: REDIS_PASSWORD
|
||||
value: "slaskredis"
|
||||
|
||||
107
go.mod
107
go.mod
@@ -1,81 +1,106 @@
|
||||
module git.tornberg.me/go-cart-actor
|
||||
|
||||
go 1.25.1
|
||||
go 1.25.3
|
||||
|
||||
require (
|
||||
git.tornberg.me/mats/go-redis-inventory v0.0.0-20251113201741-8bf0efac50ee
|
||||
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/matst80/slask-finder v0.0.0-20251118173753-f66c21cfbda4
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/rabbitmq/amqp091-go v1.10.0
|
||||
google.golang.org/grpc v1.76.0
|
||||
github.com/redis/go-redis/v9 v9.16.0
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0
|
||||
go.opentelemetry.io/otel v1.38.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0
|
||||
go.opentelemetry.io/otel/log v0.14.0
|
||||
go.opentelemetry.io/otel/metric v1.38.0
|
||||
go.opentelemetry.io/otel/sdk v1.38.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.14.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0
|
||||
go.opentelemetry.io/otel/trace v1.38.0
|
||||
google.golang.org/grpc v1.77.0
|
||||
google.golang.org/protobuf v1.36.10
|
||||
k8s.io/api v0.34.1
|
||||
k8s.io/apimachinery v0.34.1
|
||||
k8s.io/client-go v0.34.1
|
||||
k8s.io/api v0.34.2
|
||||
k8s.io/apimachinery v0.34.2
|
||||
k8s.io/client-go v0.34.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/RoaringBitmap/roaring/v2 v2.10.0 // indirect
|
||||
github.com/RoaringBitmap/roaring/v2 v2.14.4 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.0 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/getkin/kin-openapi v0.132.0 // indirect
|
||||
github.com/getkin/kin-openapi v0.133.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
|
||||
github.com/go-openapi/swag v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/cmdutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/fileutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/mangling v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/netutils v0.25.1 // indirect
|
||||
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/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.3 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.3 // indirect
|
||||
github.com/go-openapi/swag v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/cmdutils v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/fileutils v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/mangling v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/netutils v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.3 // 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/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // 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/mailru/easyjson v0.9.1 // 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/oapi-codegen/oapi-codegen/v2 v2.5.1 // 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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // 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/prometheus/common v0.67.3 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/speakeasy-api/jsonpath v0.6.2 // indirect
|
||||
github.com/speakeasy-api/openapi-overlay v0.10.3 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||
github.com/woodsbury/decimal128 v1.3.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // 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
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
|
||||
golang.org/x/mod v0.30.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/oauth2 v0.33.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/term v0.37.0 // indirect
|
||||
golang.org/x/text v0.31.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
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba // 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
|
||||
|
||||
310
go.sum
310
go.sum
@@ -1,67 +1,78 @@
|
||||
github.com/RoaringBitmap/roaring/v2 v2.10.0 h1:HbJ8Cs71lfCJyvmSptxeMX2PtvOC8yonlU0GQcy2Ak0=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.10.0/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
|
||||
git.tornberg.me/mats/go-redis-inventory v0.0.0-20251113201741-8bf0efac50ee h1:9K/INdO9y/hAjlERsYkJWAla6BUEAPXtChVLfYtWdGI=
|
||||
git.tornberg.me/mats/go-redis-inventory v0.0.0-20251113201741-8bf0efac50ee/go.mod h1:jrDU55O7sdN2RJr99upmig/FAla/mW1Cdju7834TXug=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.14.4 h1:4aKySrrg9G/5oRtJ3TrZLObVqxgQ9f1znCRBwEwjuVw=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.14.4/go.mod h1:oMvV6omPWr+2ifRdeZvVJyaz+aoEUopyv5iH0u/+wbY=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM=
|
||||
github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
|
||||
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
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/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589 h1:VJ/jVUWr+r4MQA7U/cscbbXRuwh1PfPCUUItYAjlKN4=
|
||||
github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589/go.mod h1:IeI20psFPeg2n1jxwbkYCmkpYsXsJqB7qmoqCIlX80s=
|
||||
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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
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/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
||||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
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=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
|
||||
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
|
||||
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
|
||||
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
|
||||
github.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8=
|
||||
github.com/go-openapi/swag v0.25.1/go.mod h1:bzONdGlT0fkStgGPd3bhZf1MnuPkf2YAys6h+jZipOo=
|
||||
github.com/go-openapi/swag/cmdutils v0.25.1 h1:nDke3nAFDArAa631aitksFGj2omusks88GF1VwdYqPY=
|
||||
github.com/go-openapi/swag/cmdutils v0.25.1/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
|
||||
github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
|
||||
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
|
||||
github.com/go-openapi/swag/fileutils v0.25.1 h1:rSRXapjQequt7kqalKXdcpIegIShhTPXx7yw0kek2uU=
|
||||
github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M=
|
||||
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
|
||||
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
|
||||
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
|
||||
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
|
||||
github.com/go-openapi/swag/mangling v0.25.1 h1:XzILnLzhZPZNtmxKaz/2xIGPQsBsvmCjrJOWGNz/ync=
|
||||
github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ=
|
||||
github.com/go-openapi/swag/netutils v0.25.1 h1:2wFLYahe40tDUHfKT1GRC4rfa5T1B4GWZ+msEFA4Fl4=
|
||||
github.com/go-openapi/swag/netutils v0.25.1/go.mod h1:CAkkvqnUJX8NV96tNhEQvKz8SQo2KF0f7LleiJwIeRE=
|
||||
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
|
||||
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
|
||||
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
|
||||
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-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
|
||||
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
|
||||
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
|
||||
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
|
||||
github.com/go-openapi/swag v0.25.3 h1:FAa5wJXyDtI7yUztKDfZxDrSx+8WTg31MfCQ9s3PV+s=
|
||||
github.com/go-openapi/swag v0.25.3/go.mod h1:tX9vI8Mj8Ny+uCEk39I1QADvIPI7lkndX4qCsEqhkS8=
|
||||
github.com/go-openapi/swag/cmdutils v0.25.3 h1:EIwGxN143JCThNHnqfqs85R8lJcJG06qjJRZp3VvjLI=
|
||||
github.com/go-openapi/swag/cmdutils v0.25.3/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
|
||||
github.com/go-openapi/swag/conv v0.25.3 h1:PcB18wwfba7MN5BVlBIV+VxvUUeC2kEuCEyJ2/t2X7E=
|
||||
github.com/go-openapi/swag/conv v0.25.3/go.mod h1:n4Ibfwhn8NJnPXNRhBO5Cqb9ez7alBR40JS4rbASUPU=
|
||||
github.com/go-openapi/swag/fileutils v0.25.3 h1:P52Uhd7GShkeU/a1cBOuqIcHMHBrA54Z2t5fLlE85SQ=
|
||||
github.com/go-openapi/swag/fileutils v0.25.3/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk=
|
||||
github.com/go-openapi/swag/jsonname v0.25.3 h1:U20VKDS74HiPaLV7UZkztpyVOw3JNVsit+w+gTXRj0A=
|
||||
github.com/go-openapi/swag/jsonname v0.25.3/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.3 h1:kV7wer79KXUM4Ea4tBdAVTU842Rg6tWstX3QbM4fGdw=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.3/go.mod h1:ILcKqe4HC1VEZmJx51cVuZQ6MF8QvdfXsQfiaCs0z9o=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3 h1:/i3E9hBujtXfHy91rjtwJ7Fgv5TuDHgnSrYjhFxwxOw=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3/go.mod h1:8kYfCR2rHyOj25HVvxL5Nm8wkfzggddgjZm6RgjT8Ao=
|
||||
github.com/go-openapi/swag/loading v0.25.3 h1:Nn65Zlzf4854MY6Ft0JdNrtnHh2bdcS/tXckpSnOb2Y=
|
||||
github.com/go-openapi/swag/loading v0.25.3/go.mod h1:xajJ5P4Ang+cwM5gKFrHBgkEDWfLcsAKepIuzTmOb/c=
|
||||
github.com/go-openapi/swag/mangling v0.25.3 h1:rGIrEzXaYWuUW1MkFmG3pcH+EIA0/CoUkQnIyB6TUyo=
|
||||
github.com/go-openapi/swag/mangling v0.25.3/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg=
|
||||
github.com/go-openapi/swag/netutils v0.25.3 h1:XWXHZfL/65ABiv8rvGp9dtE0C6QHTYkCrNV77jTl358=
|
||||
github.com/go-openapi/swag/netutils v0.25.3/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg=
|
||||
github.com/go-openapi/swag/stringutils v0.25.3 h1:nAmWq1fUTWl/XiaEPwALjp/8BPZJun70iDHRNq/sH6w=
|
||||
github.com/go-openapi/swag/stringutils v0.25.3/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
||||
github.com/go-openapi/swag/typeutils v0.25.3 h1:2w4mEEo7DQt3V4veWMZw0yTPQibiL3ri2fdDV4t2TQc=
|
||||
github.com/go-openapi/swag/typeutils v0.25.3/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.3 h1:LKTJjCn/W1ZfMec0XDL4Vxh8kyAnv1orH5F2OREDUrg=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.3/go.mod h1:Y7QN6Wc5DOBXK14/xeo1cQlq0EA0wvLoSv13gDQoCao=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
|
||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
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=
|
||||
@@ -69,34 +80,22 @@ github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncV
|
||||
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/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
|
||||
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=
|
||||
@@ -114,10 +113,10 @@ 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/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/matst80/slask-finder v0.0.0-20251118173753-f66c21cfbda4 h1:yWwWkCaXhwrp5EAd6Z2Rd/n823K0Fz/A3+bqcAcn0qk=
|
||||
github.com/matst80/slask-finder v0.0.0-20251118173753-f66c21cfbda4/go.mod h1:aqCC0Y1Jv+DhL36YHXf+0bZZkpQNMe9yFMcwgRSJ+Rc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -130,167 +129,162 @@ 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/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
|
||||
github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 h1:5vHNY1uuPBRBWqB2Dp0G7YB03phxLQZupZTIZaeorjc=
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1/go.mod h1:ro0npU1BWkcGpCgGD9QwPp44l5OIZ94tB3eabnT7DjQ=
|
||||
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 v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
||||
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||
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/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||
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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI=
|
||||
github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q=
|
||||
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||
github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2xN2Q=
|
||||
github.com/prometheus/common v0.67.3/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
|
||||
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/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4=
|
||||
github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
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/speakeasy-api/jsonpath v0.6.2 h1:Mys71yd6u8kuowNCR0gCVPlVAHCmKtoGXYoAtcEbqXQ=
|
||||
github.com/speakeasy-api/jsonpath v0.6.2/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw=
|
||||
github.com/speakeasy-api/openapi-overlay v0.10.3 h1:70een4vwHyslIp796vM+ox6VISClhtXsCjrQNhxwvWs=
|
||||
github.com/speakeasy-api/openapi-overlay v0.10.3/go.mod h1:RJjV0jbUHqXLS0/Mxv5XE7LAnJHqHw+01RDdpoGqiyY=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/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/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
|
||||
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
|
||||
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=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 h1:bwnLpizECbPr1RrQ27waeY2SPIPeccCx/xLuoYADZ9s=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0/go.mod h1:3nWlOiiqA9UtUnrcNk82mYasNxD8ehOspL0gOfEo6Y4=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 h1:OMqPldHt79PqWKOMYIAQs3CxAi7RLgPxwfFSwr4ZxtM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0/go.mod h1:1biG4qiqTxKiUCtoWDPpL3fB3KxVwCiGw81j3nKMuHE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 h1:vl9obrcoWVKp/lwl8tRE33853I8Xru9HFbw/skNeLs8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0/go.mod h1:GAXRxmLJcVM3u22IjTg74zWBrRCKq8BnOqUVLodpcpw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk=
|
||||
go.opentelemetry.io/otel/log v0.14.0 h1:2rzJ+pOAZ8qmZ3DDHg73NEKzSZkhkGIua9gXtxNGgrM=
|
||||
go.opentelemetry.io/otel/log v0.14.0/go.mod h1:5jRG92fEAgx0SU/vFPxmJvhIuDU9E1SUnEQrMlJpOno=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk/log v0.14.0 h1:JU/U3O7N6fsAXj0+CXz21Czg532dW2V4gG1HE/e8Zrg=
|
||||
go.opentelemetry.io/otel/sdk/log v0.14.0/go.mod h1:imQvII+0ZylXfKU7/wtOND8Hn4OpT3YUoIgqJVksUkM=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 h1:Ijbtz+JKXl8T2MngiwqBlPaHqc4YCaP/i13Qrow6gAM=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0/go.mod h1:dCU8aEL6q+L9cYTqcVOk8rM9Tp8WdnHOPLiBgp0SGOA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
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/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
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/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
||||
golang.org/x/oauth2 v0.33.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/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.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/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
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/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
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=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff h1:A90eA31Wq6HOMIQlLfzFwzqGKBTuaVztYu/g8sn+8Zc=
|
||||
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/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba h1:B14OtaXuMaCQsl2deSvNkyPKIzq3BjfxQp8d00QyWx4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:G5IanEx8/PgI9w6CFcYQf7jMtHQhZruvfM1i3qOqk5U=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba h1:UKgtfRM7Yh93Sya0Fo8ZzhDP4qBckrrxEr2oF5UIVb8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
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=
|
||||
@@ -307,20 +301,18 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD
|
||||
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=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM=
|
||||
k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk=
|
||||
k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4=
|
||||
k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
|
||||
k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY=
|
||||
k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8=
|
||||
k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY=
|
||||
k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw=
|
||||
k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4=
|
||||
k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
|
||||
k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M=
|
||||
k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE=
|
||||
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -25,8 +26,8 @@ type DiskStorage[V any] struct {
|
||||
}
|
||||
|
||||
type LogStorage[V any] interface {
|
||||
LoadEvents(id uint64, grain Grain[V]) error
|
||||
AppendEvent(id uint64, msg ...proto.Message) error
|
||||
LoadEvents(ctx context.Context, id uint64, grain Grain[V]) error
|
||||
AppendMutations(id uint64, msg ...proto.Message) error
|
||||
}
|
||||
|
||||
func NewDiskStorage[V any](path string, registry MutationRegistry) *DiskStorage[V] {
|
||||
@@ -86,7 +87,7 @@ 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 {
|
||||
func (s *DiskStorage[V]) LoadEvents(ctx context.Context, 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
|
||||
@@ -99,16 +100,18 @@ func (s *DiskStorage[V]) LoadEvents(id uint64, grain Grain[V]) error {
|
||||
}
|
||||
defer fh.Close()
|
||||
return s.Load(fh, func(msg proto.Message) {
|
||||
s.registry.Apply(grain, msg)
|
||||
s.registry.Apply(ctx, grain, msg)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *DiskStorage[V]) Close() {
|
||||
if s.queue != nil {
|
||||
s.save()
|
||||
}
|
||||
close(s.done)
|
||||
}
|
||||
|
||||
func (s *DiskStorage[V]) AppendEvent(id uint64, msg ...proto.Message) error {
|
||||
func (s *DiskStorage[V]) AppendMutations(id uint64, msg ...proto.Message) error {
|
||||
if s.queue != nil {
|
||||
queue := make([]QueueEvent, 0)
|
||||
data, found := s.queue.Load(id)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/gogo/protobuf/proto"
|
||||
@@ -12,8 +13,8 @@ type MutationResult[V any] struct {
|
||||
}
|
||||
|
||||
type GrainPool[V any] interface {
|
||||
Apply(id uint64, mutation ...proto.Message) (*MutationResult[V], error)
|
||||
Get(id uint64) (V, error)
|
||||
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[V], error)
|
||||
Get(ctx context.Context, id uint64) (V, error)
|
||||
OwnerHost(id uint64) (Host, bool)
|
||||
Hostname() string
|
||||
TakeOwnership(id uint64)
|
||||
@@ -22,8 +23,10 @@ type GrainPool[V any] interface {
|
||||
Negotiate(otherHosts []string)
|
||||
GetLocalIds() []uint64
|
||||
RemoveHost(host string)
|
||||
AddRemoteHost(host string)
|
||||
IsHealthy() bool
|
||||
IsKnown(string) bool
|
||||
GetPubSub() *PubSub
|
||||
Close()
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ import (
|
||||
"time"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/reflection"
|
||||
)
|
||||
@@ -16,13 +20,77 @@ import (
|
||||
// It delegates to a grain pool and cluster operations to a synced pool.
|
||||
type ControlServer[V any] struct {
|
||||
messages.UnimplementedControlPlaneServer
|
||||
|
||||
pool GrainPool[V]
|
||||
}
|
||||
|
||||
const name = "grpc_server"
|
||||
|
||||
var (
|
||||
tracer = otel.Tracer(name)
|
||||
meter = otel.Meter(name)
|
||||
logger = otelslog.NewLogger(name)
|
||||
pingCalls metric.Int64Counter
|
||||
negotiateCalls metric.Int64Counter
|
||||
getLocalActorIdsCalls metric.Int64Counter
|
||||
announceOwnershipCalls metric.Int64Counter
|
||||
announceExpiryCalls metric.Int64Counter
|
||||
closingCalls metric.Int64Counter
|
||||
)
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
pingCalls, err = meter.Int64Counter("grpc.ping_calls",
|
||||
metric.WithDescription("Number of ping calls"),
|
||||
metric.WithUnit("{calls}"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
negotiateCalls, err = meter.Int64Counter("grpc.negotiate_calls",
|
||||
metric.WithDescription("Number of negotiate calls"),
|
||||
metric.WithUnit("{calls}"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
getLocalActorIdsCalls, err = meter.Int64Counter("grpc.get_local_actor_ids_calls",
|
||||
metric.WithDescription("Number of get local actor ids calls"),
|
||||
metric.WithUnit("{calls}"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
announceOwnershipCalls, err = meter.Int64Counter("grpc.announce_ownership_calls",
|
||||
metric.WithDescription("Number of announce ownership calls"),
|
||||
metric.WithUnit("{calls}"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
announceExpiryCalls, err = meter.Int64Counter("grpc.announce_expiry_calls",
|
||||
metric.WithDescription("Number of announce expiry calls"),
|
||||
metric.WithUnit("{calls}"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
closingCalls, err = meter.Int64Counter("grpc.closing_calls",
|
||||
metric.WithDescription("Number of closing calls"),
|
||||
metric.WithUnit("{calls}"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ControlServer[V]) AnnounceOwnership(ctx context.Context, req *messages.OwnershipAnnounce) (*messages.OwnerChangeAck, error) {
|
||||
ctx, span := tracer.Start(ctx, "grpc_announce_ownership")
|
||||
defer span.End()
|
||||
span.SetAttributes(
|
||||
attribute.String("component", "controlplane"),
|
||||
attribute.String("host", req.Host),
|
||||
attribute.Int("id_count", len(req.Ids)),
|
||||
)
|
||||
logger.InfoContext(ctx, "announce ownership", "host", req.Host, "id_count", len(req.Ids))
|
||||
announceOwnershipCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
|
||||
|
||||
err := s.pool.HandleOwnershipChange(req.Host, req.Ids)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return &messages.OwnerChangeAck{
|
||||
Accepted: false,
|
||||
Message: "owner change failed",
|
||||
@@ -37,7 +105,20 @@ func (s *ControlServer[V]) AnnounceOwnership(ctx context.Context, req *messages.
|
||||
}
|
||||
|
||||
func (s *ControlServer[V]) AnnounceExpiry(ctx context.Context, req *messages.ExpiryAnnounce) (*messages.OwnerChangeAck, error) {
|
||||
ctx, span := tracer.Start(ctx, "grpc_announce_expiry")
|
||||
defer span.End()
|
||||
span.SetAttributes(
|
||||
attribute.String("component", "controlplane"),
|
||||
attribute.String("host", req.Host),
|
||||
attribute.Int("id_count", len(req.Ids)),
|
||||
)
|
||||
logger.InfoContext(ctx, "announce expiry", "host", req.Host, "id_count", len(req.Ids))
|
||||
announceExpiryCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
|
||||
|
||||
err := s.pool.HandleRemoteExpiry(req.Host, req.Ids)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
}
|
||||
return &messages.OwnerChangeAck{
|
||||
Accepted: err == nil,
|
||||
Message: "expiry acknowledged",
|
||||
@@ -46,15 +127,28 @@ func (s *ControlServer[V]) AnnounceExpiry(ctx context.Context, req *messages.Exp
|
||||
|
||||
// ControlPlane: Ping
|
||||
func (s *ControlServer[V]) Ping(ctx context.Context, _ *messages.Empty) (*messages.PingReply, error) {
|
||||
|
||||
host := s.pool.Hostname()
|
||||
|
||||
pingCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", host)))
|
||||
|
||||
// log.Printf("got ping")
|
||||
return &messages.PingReply{
|
||||
Host: s.pool.Hostname(),
|
||||
Host: host,
|
||||
UnixTime: time.Now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ControlPlane: Negotiate (merge host views)
|
||||
func (s *ControlServer[V]) Negotiate(ctx context.Context, req *messages.NegotiateRequest) (*messages.NegotiateReply, error) {
|
||||
ctx, span := tracer.Start(ctx, "grpc_negotiate")
|
||||
defer span.End()
|
||||
span.SetAttributes(
|
||||
attribute.String("component", "controlplane"),
|
||||
attribute.Int("known_hosts_count", len(req.KnownHosts)),
|
||||
)
|
||||
logger.InfoContext(ctx, "negotiate", "known_hosts_count", len(req.KnownHosts))
|
||||
negotiateCalls.Add(ctx, 1)
|
||||
|
||||
s.pool.Negotiate(req.KnownHosts)
|
||||
return &messages.NegotiateReply{Hosts: req.GetKnownHosts()}, nil
|
||||
@@ -62,13 +156,33 @@ func (s *ControlServer[V]) Negotiate(ctx context.Context, req *messages.Negotiat
|
||||
|
||||
// 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
|
||||
ctx, span := tracer.Start(ctx, "grpc_get_local_actor_ids")
|
||||
defer span.End()
|
||||
ids := s.pool.GetLocalIds()
|
||||
span.SetAttributes(
|
||||
attribute.String("component", "controlplane"),
|
||||
attribute.Int("id_count", len(ids)),
|
||||
)
|
||||
logger.InfoContext(ctx, "get local actor ids", "id_count", len(ids))
|
||||
getLocalActorIdsCalls.Add(ctx, 1)
|
||||
|
||||
return &messages.ActorIdsReply{Ids: ids}, 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())
|
||||
ctx, span := tracer.Start(ctx, "grpc_closing")
|
||||
defer span.End()
|
||||
host := req.GetHost()
|
||||
span.SetAttributes(
|
||||
attribute.String("component", "controlplane"),
|
||||
attribute.String("host", host),
|
||||
)
|
||||
logger.InfoContext(ctx, "closing notice", "host", host)
|
||||
closingCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", host)))
|
||||
|
||||
if host != "" {
|
||||
s.pool.RemoveHost(host)
|
||||
}
|
||||
return &messages.OwnerChangeAck{
|
||||
Accepted: true,
|
||||
|
||||
47
pkg/actor/log_listerner.go
Normal file
47
pkg/actor/log_listerner.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/matst80/slask-finder/pkg/messaging"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
type LogListener interface {
|
||||
AppendMutations(id uint64, msg ...ApplyResult)
|
||||
}
|
||||
|
||||
type AmqpListener struct {
|
||||
conn *amqp.Connection
|
||||
transformer func(id uint64, msg []ApplyResult) (any, error)
|
||||
}
|
||||
|
||||
func NewAmqpListener(conn *amqp.Connection, transformer func(id uint64, msg []ApplyResult) (any, error)) *AmqpListener {
|
||||
return &AmqpListener{
|
||||
conn: conn,
|
||||
transformer: transformer,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *AmqpListener) DefineTopics() {
|
||||
ch, err := l.conn.Channel()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open a channel: %v", err)
|
||||
}
|
||||
defer ch.Close()
|
||||
if err := messaging.DefineTopic(ch, "cart", "mutation"); err != nil {
|
||||
log.Fatalf("Failed to declare topic mutation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *AmqpListener) AppendMutations(id uint64, msg ...ApplyResult) {
|
||||
data, err := l.transformer(id, msg)
|
||||
if err != nil {
|
||||
log.Printf("Failed to transform mutation event: %v", err)
|
||||
return
|
||||
}
|
||||
err = messaging.SendChange(l.conn, "cart", "mutation", data)
|
||||
if err != nil {
|
||||
log.Printf("Failed to send mutation event: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"reflect"
|
||||
"sync"
|
||||
|
||||
"github.com/gogo/protobuf/proto"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
)
|
||||
|
||||
type ApplyResult struct {
|
||||
@@ -15,11 +17,30 @@ type ApplyResult struct {
|
||||
Error error `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type MutationProcessor interface {
|
||||
Process(ctx context.Context, grain any) error
|
||||
}
|
||||
|
||||
type BasicMutationProcessor[V any] struct {
|
||||
processor func(ctx context.Context, grain V) error
|
||||
}
|
||||
|
||||
func NewMutationProcessor[V any](process func(ctx context.Context, grain V) error) MutationProcessor {
|
||||
return &BasicMutationProcessor[V]{
|
||||
processor: process,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *BasicMutationProcessor[V]) Process(ctx context.Context, grain any) error {
|
||||
return p.processor(ctx, grain.(V))
|
||||
}
|
||||
|
||||
type MutationRegistry interface {
|
||||
Apply(grain any, msg ...proto.Message) ([]ApplyResult, error)
|
||||
Apply(ctx context.Context, grain any, msg ...proto.Message) ([]ApplyResult, error)
|
||||
RegisterMutations(handlers ...MutationHandler)
|
||||
Create(typeName string) (proto.Message, bool)
|
||||
GetTypeName(msg proto.Message) (string, bool)
|
||||
RegisterProcessor(processor ...MutationProcessor)
|
||||
//GetStorageEvent(msg proto.Message) StorageEvent
|
||||
//FromStorageEvent(event StorageEvent) (proto.Message, error)
|
||||
}
|
||||
@@ -27,6 +48,7 @@ type MutationRegistry interface {
|
||||
type ProtoMutationRegistry struct {
|
||||
mutationRegistryMu sync.RWMutex
|
||||
mutationRegistry map[reflect.Type]MutationHandler
|
||||
processors []MutationProcessor
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -114,9 +136,14 @@ func NewMutationRegistry() MutationRegistry {
|
||||
return &ProtoMutationRegistry{
|
||||
mutationRegistry: make(map[reflect.Type]MutationHandler),
|
||||
mutationRegistryMu: sync.RWMutex{},
|
||||
processors: make([]MutationProcessor, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ProtoMutationRegistry) RegisterProcessor(processors ...MutationProcessor) {
|
||||
r.processors = append(r.processors, processors...)
|
||||
}
|
||||
|
||||
func (r *ProtoMutationRegistry) RegisterMutations(handlers ...MutationHandler) {
|
||||
r.mutationRegistryMu.Lock()
|
||||
defer r.mutationRegistryMu.Unlock()
|
||||
@@ -165,33 +192,65 @@ func (r *ProtoMutationRegistry) Create(typeName string) (proto.Message, bool) {
|
||||
// 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) {
|
||||
func (r *ProtoMutationRegistry) Apply(ctx context.Context, grain any, msg ...proto.Message) ([]ApplyResult, error) {
|
||||
|
||||
parentCtx, span := tracer.Start(ctx, "apply mutations")
|
||||
defer span.End()
|
||||
span.SetAttributes(
|
||||
attribute.String("component", "registry"),
|
||||
attribute.Int("mutations", len(msg)),
|
||||
)
|
||||
|
||||
results := make([]ApplyResult, 0, len(msg))
|
||||
|
||||
if grain == nil {
|
||||
return results, fmt.Errorf("nil grain")
|
||||
}
|
||||
// Nil slice of mutations still treated as an error (call contract violation).
|
||||
if msg == nil {
|
||||
return results, fmt.Errorf("nil mutation message")
|
||||
}
|
||||
|
||||
for _, m := range msg {
|
||||
// Ignore nil mutation elements (untyped or typed nil pointers) silently; they carry no data.
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
// Typed nil: interface holds concrete proto message type whose pointer value is nil.
|
||||
rv := reflect.ValueOf(m)
|
||||
if rv.Kind() == reflect.Ptr && rv.IsNil() {
|
||||
continue
|
||||
}
|
||||
rt := indirectType(reflect.TypeOf(m))
|
||||
_, msgSpan := tracer.Start(parentCtx, rt.Name())
|
||||
|
||||
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
|
||||
}
|
||||
} else {
|
||||
err := entry.Handle(grain, m)
|
||||
if err != nil {
|
||||
msgSpan.RecordError(err)
|
||||
}
|
||||
results = append(results, ApplyResult{Error: err, Type: rt.Name(), Mutation: m})
|
||||
}
|
||||
msgSpan.End()
|
||||
}
|
||||
|
||||
// if entry.updateTotals {
|
||||
// grain.UpdateTotals()
|
||||
// }
|
||||
if len(results) > 0 {
|
||||
processCtx, processSpan := tracer.Start(ctx, "after mutation processors")
|
||||
defer processSpan.End()
|
||||
for _, processor := range r.processors {
|
||||
|
||||
err := processor.Process(processCtx, grain)
|
||||
if err != nil {
|
||||
return results, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"slices"
|
||||
@@ -21,8 +22,8 @@ func TestRegisteredMutationBasics(t *testing.T) {
|
||||
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
|
||||
cp := msg
|
||||
state.lastAdded = cp
|
||||
return nil
|
||||
},
|
||||
func() *messages.AddItem { return &messages.AddItem{} },
|
||||
@@ -78,7 +79,7 @@ func TestRegisteredMutationBasics(t *testing.T) {
|
||||
// Apply happy path
|
||||
state := &cartState{}
|
||||
add := &messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
|
||||
if _, err := reg.Apply(state, add); err != nil {
|
||||
if _, err := reg.Apply(context.Background(), state, add); err != nil {
|
||||
t.Fatalf("Apply returned error: %v", err)
|
||||
}
|
||||
if state.calls != 1 {
|
||||
@@ -94,12 +95,12 @@ func TestRegisteredMutationBasics(t *testing.T) {
|
||||
}
|
||||
|
||||
// Apply nil message
|
||||
if _, err := reg.Apply(state, nil); err == nil {
|
||||
if _, err := reg.Apply(context.Background(), 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) {
|
||||
if _, err := reg.Apply(context.Background(), state, &messages.Noop{}); !errors.Is(err, ErrMutationNotRegistered) {
|
||||
t.Fatalf("expected ErrMutationNotRegistered, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
93
pkg/actor/pubsub.go
Normal file
93
pkg/actor/pubsub.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Event represents an event to be published.
|
||||
type Event struct {
|
||||
Topic string
|
||||
Payload interface{}
|
||||
}
|
||||
|
||||
// NotifyFunc is a function to notify a grain of an event.
|
||||
type NotifyFunc func(grainID uint64, event Event)
|
||||
|
||||
// Subscribable is an interface for grains that can receive notifications.
|
||||
type Subscribable interface {
|
||||
Notify(event Event)
|
||||
UpdateSubscriptions(pubsub *PubSub)
|
||||
}
|
||||
|
||||
// PubSub manages subscriptions for grains to topics.
|
||||
// Topics are strings, e.g., "sku:12345"
|
||||
// Subscribers are grain IDs (uint64)
|
||||
type PubSub struct {
|
||||
subscribers map[string][]uint64
|
||||
mu sync.RWMutex
|
||||
notify NotifyFunc
|
||||
}
|
||||
|
||||
// NewPubSub creates a new PubSub instance.
|
||||
func NewPubSub(notify NotifyFunc) *PubSub {
|
||||
return &PubSub{
|
||||
subscribers: make(map[string][]uint64),
|
||||
notify: notify,
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe adds a grain ID to the subscribers of a topic.
|
||||
func (p *PubSub) Subscribe(topic string, grainID uint64) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.subscribers[topic] = append(p.subscribers[topic], grainID)
|
||||
}
|
||||
|
||||
// Unsubscribe removes a grain ID from the subscribers of a topic.
|
||||
func (p *PubSub) Unsubscribe(topic string, grainID uint64) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
list := p.subscribers[topic]
|
||||
for i, id := range list {
|
||||
if id == grainID {
|
||||
p.subscribers[topic] = append(list[:i], list[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
// If list is empty, could delete, but not necessary
|
||||
}
|
||||
|
||||
// UnsubscribeAll removes the grain ID from all topics.
|
||||
func (p *PubSub) UnsubscribeAll(grainID uint64) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for topic, list := range p.subscribers {
|
||||
newList := make([]uint64, 0, len(list))
|
||||
for _, id := range list {
|
||||
if id != grainID {
|
||||
newList = append(newList, id)
|
||||
}
|
||||
}
|
||||
if len(newList) == 0 {
|
||||
delete(p.subscribers, topic)
|
||||
} else {
|
||||
p.subscribers[topic] = newList
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetSubscribers returns a copy of the subscriber IDs for a topic.
|
||||
func (p *PubSub) GetSubscribers(topic string) []uint64 {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
list := p.subscribers[topic]
|
||||
return append([]uint64(nil), list...)
|
||||
}
|
||||
|
||||
// Publish sends an event to all subscribers of the topic.
|
||||
func (p *PubSub) Publish(event Event) {
|
||||
subs := p.GetSubscribers(event.Topic)
|
||||
for _, id := range subs {
|
||||
p.notify(id, event)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"maps"
|
||||
@@ -15,11 +16,13 @@ type SimpleGrainPool[V any] struct {
|
||||
localMu sync.RWMutex
|
||||
grains map[uint64]Grain[V]
|
||||
mutationRegistry MutationRegistry
|
||||
spawn func(id uint64) (Grain[V], error)
|
||||
spawn func(ctx context.Context, id uint64) (Grain[V], error)
|
||||
spawnHost func(host string) (Host, error)
|
||||
listeners []LogListener
|
||||
storage LogStorage[V]
|
||||
ttl time.Duration
|
||||
poolSize int
|
||||
pubsub *PubSub
|
||||
|
||||
// Cluster coordination --------------------------------------------------
|
||||
hostname string
|
||||
@@ -34,7 +37,7 @@ type SimpleGrainPool[V any] struct {
|
||||
|
||||
type GrainPoolConfig[V any] struct {
|
||||
Hostname string
|
||||
Spawn func(id uint64) (Grain[V], error)
|
||||
Spawn func(ctx context.Context, id uint64) (Grain[V], error)
|
||||
SpawnHost func(host string) (Host, error)
|
||||
TTL time.Duration
|
||||
PoolSize int
|
||||
@@ -66,6 +69,19 @@ func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V],
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (p *SimpleGrainPool[V]) AddListener(listener LogListener) {
|
||||
p.listeners = append(p.listeners, listener)
|
||||
}
|
||||
|
||||
func (p *SimpleGrainPool[V]) RemoveListener(listener LogListener) {
|
||||
for i, l := range p.listeners {
|
||||
if l == listener {
|
||||
p.listeners = append(p.listeners[:i], p.listeners[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *SimpleGrainPool[V]) purge() {
|
||||
purgeLimit := time.Now().Add(-p.ttl)
|
||||
purgedIds := make([]uint64, 0, len(p.grains))
|
||||
@@ -73,7 +89,9 @@ func (p *SimpleGrainPool[V]) purge() {
|
||||
for id, grain := range p.grains {
|
||||
if grain.GetLastAccess().Before(purgeLimit) {
|
||||
purgedIds = append(purgedIds, id)
|
||||
|
||||
if p.pubsub != nil {
|
||||
p.pubsub.UnsubscribeAll(id)
|
||||
}
|
||||
delete(p.grains, id)
|
||||
}
|
||||
}
|
||||
@@ -143,6 +161,10 @@ func (p *SimpleGrainPool[V]) TakeOwnership(id uint64) {
|
||||
p.broadcastOwnership([]uint64{id})
|
||||
}
|
||||
|
||||
func (p *SimpleGrainPool[V]) AddRemoteHost(host string) {
|
||||
p.AddRemote(host)
|
||||
}
|
||||
|
||||
func (p *SimpleGrainPool[V]) AddRemote(host string) (Host, error) {
|
||||
if host == "" {
|
||||
return nil, fmt.Errorf("host is empty")
|
||||
@@ -348,7 +370,7 @@ func (p *SimpleGrainPool[V]) broadcastOwnership(ids []uint64) {
|
||||
// go p.statsUpdate()
|
||||
}
|
||||
|
||||
func (p *SimpleGrainPool[V]) getOrClaimGrain(id uint64) (Grain[V], error) {
|
||||
func (p *SimpleGrainPool[V]) getOrClaimGrain(ctx context.Context, id uint64) (Grain[V], error) {
|
||||
p.localMu.RLock()
|
||||
grain, exists := p.grains[id]
|
||||
p.localMu.RUnlock()
|
||||
@@ -356,7 +378,7 @@ func (p *SimpleGrainPool[V]) getOrClaimGrain(id uint64) (Grain[V], error) {
|
||||
return grain, nil
|
||||
}
|
||||
|
||||
grain, err := p.spawn(id)
|
||||
grain, err := p.spawn(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -371,27 +393,35 @@ func (p *SimpleGrainPool[V]) getOrClaimGrain(id uint64) (Grain[V], error) {
|
||||
// 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)
|
||||
func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[*V], error) {
|
||||
grain, err := p.getOrClaimGrain(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mutations, err := p.mutationRegistry.Apply(grain, mutation...)
|
||||
mutations, err := p.mutationRegistry.Apply(ctx, grain, mutation...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.storage != nil {
|
||||
go func() {
|
||||
if err := p.storage.AppendEvent(id, mutation...); err != nil {
|
||||
if err := p.storage.AppendMutations(id, mutation...); err != nil {
|
||||
log.Printf("failed to store mutation for grain %d: %v", id, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
for _, listener := range p.listeners {
|
||||
go listener.AppendMutations(id, mutations...)
|
||||
}
|
||||
result, err := grain.GetCurrentState()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.pubsub != nil {
|
||||
if sub, ok := any(grain).(Subscribable); ok {
|
||||
sub.UpdateSubscriptions(p.pubsub)
|
||||
}
|
||||
}
|
||||
return &MutationResult[*V]{
|
||||
Result: result,
|
||||
Mutations: mutations,
|
||||
@@ -399,8 +429,8 @@ func (p *SimpleGrainPool[V]) Apply(id uint64, mutation ...proto.Message) (*Mutat
|
||||
}
|
||||
|
||||
// Get returns the current state of a grain.
|
||||
func (p *SimpleGrainPool[V]) Get(id uint64) (*V, error) {
|
||||
grain, err := p.getOrClaimGrain(id)
|
||||
func (p *SimpleGrainPool[V]) Get(ctx context.Context, id uint64) (*V, error) {
|
||||
grain, err := p.getOrClaimGrain(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -420,6 +450,21 @@ func (p *SimpleGrainPool[V]) Hostname() string {
|
||||
return p.hostname
|
||||
}
|
||||
|
||||
// GetPubSub returns the pubsub instance.
|
||||
func (p *SimpleGrainPool[V]) GetPubSub() *PubSub {
|
||||
return p.pubsub
|
||||
}
|
||||
|
||||
func (p *SimpleGrainPool[V]) SetPubSub(pubsub *PubSub) {
|
||||
p.pubsub = pubsub
|
||||
}
|
||||
|
||||
func (p *SimpleGrainPool[V]) Publish(event Event) {
|
||||
if p.pubsub != nil {
|
||||
p.pubsub.Publish(event)
|
||||
}
|
||||
}
|
||||
|
||||
// Close notifies remotes that this host is shutting down.
|
||||
func (p *SimpleGrainPool[V]) Close() {
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"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"
|
||||
)
|
||||
@@ -33,11 +34,12 @@ type ItemMeta struct {
|
||||
type CartItem struct {
|
||||
Id uint32 `json:"id"`
|
||||
ItemId uint32 `json:"itemId,omitempty"`
|
||||
ParentId uint32 `json:"parentId,omitempty"`
|
||||
ParentId *uint32 `json:"parentId,omitempty"`
|
||||
Sku string `json:"sku"`
|
||||
Price Price `json:"price"`
|
||||
TotalPrice Price `json:"totalPrice"`
|
||||
OrgPrice *Price `json:"orgPrice,omitempty"`
|
||||
Tax int
|
||||
Stock StockStatus `json:"stock"`
|
||||
Quantity int `json:"qty"`
|
||||
Discount *Price `json:"discount,omitempty"`
|
||||
@@ -45,6 +47,7 @@ type CartItem struct {
|
||||
ArticleType string `json:"type,omitempty"`
|
||||
StoreId *string `json:"storeId,omitempty"`
|
||||
Meta *ItemMeta `json:"meta,omitempty"`
|
||||
SaleStatus string `json:"saleStatus"`
|
||||
}
|
||||
|
||||
type CartDelivery struct {
|
||||
@@ -62,6 +65,14 @@ type CartNotification struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type SubscriptionDetails struct {
|
||||
Id string `json:"id,omitempty"`
|
||||
Version uint16 `json:"version"`
|
||||
OfferingCode string `json:"offeringCode,omitempty"`
|
||||
SigningType string `json:"signingType,omitempty"`
|
||||
Meta json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type CartGrain struct {
|
||||
mu sync.RWMutex
|
||||
lastItemId uint32
|
||||
@@ -70,6 +81,7 @@ type CartGrain struct {
|
||||
lastAccess time.Time
|
||||
lastChange time.Time // unix seconds of last successful mutation (replay sets from event ts)
|
||||
userId string
|
||||
InventoryReserved bool `json:"inventoryReserved"`
|
||||
Id CartId `json:"id"`
|
||||
Items []*CartItem `json:"items"`
|
||||
TotalPrice *Price `json:"totalPrice"`
|
||||
@@ -81,11 +93,14 @@ type CartGrain struct {
|
||||
PaymentStatus string `json:"paymentStatus,omitempty"`
|
||||
Vouchers []*Voucher `json:"vouchers,omitempty"`
|
||||
Notifications []CartNotification `json:"cartNotification,omitempty"`
|
||||
SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"`
|
||||
}
|
||||
|
||||
type Voucher struct {
|
||||
Code string `json:"code"`
|
||||
Rules []*messages.VoucherRule `json:"rules"`
|
||||
Applied bool `json:"applied"`
|
||||
Rules []string `json:"rules"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Id uint32 `json:"id"`
|
||||
Value int64 `json:"value"`
|
||||
}
|
||||
@@ -119,8 +134,8 @@ func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
|
||||
}
|
||||
|
||||
// All voucher rules must pass (logical AND)
|
||||
for _, rule := range v.Rules {
|
||||
expr := rule.GetCondition()
|
||||
for _, expr := range v.Rules {
|
||||
|
||||
if expr == "" {
|
||||
// Empty condition treated as pass (acts like a comment / placeholder)
|
||||
continue
|
||||
@@ -138,6 +153,23 @@ func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
|
||||
return cart.Items, true
|
||||
}
|
||||
|
||||
func NewCartGrain(id uint64, ts time.Time) *CartGrain {
|
||||
return &CartGrain{
|
||||
lastItemId: 0,
|
||||
lastDeliveryId: 0,
|
||||
lastVoucherId: 0,
|
||||
lastAccess: ts,
|
||||
lastChange: ts,
|
||||
TotalDiscount: NewPrice(),
|
||||
Vouchers: []*Voucher{},
|
||||
Deliveries: []*CartDelivery{},
|
||||
Id: CartId(id),
|
||||
Items: []*CartItem{},
|
||||
TotalPrice: NewPrice(),
|
||||
SubscriptionDetails: make(map[string]*SubscriptionDetails),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CartGrain) GetId() uint64 {
|
||||
return uint64(c.Id)
|
||||
}
|
||||
@@ -155,21 +187,39 @@ 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")
|
||||
// Notify handles incoming events, e.g., inventory changes.
|
||||
func (c *CartGrain) Notify(event actor.Event) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
// Example: if event is inventory change for a SKU in the cart
|
||||
if strings.HasPrefix(event.Topic, "inventory:") {
|
||||
sku := strings.TrimPrefix(event.Topic, "inventory:")
|
||||
for _, item := range c.Items {
|
||||
if item.Sku == sku {
|
||||
// Update stock status based on payload, e.g., if payload is bool available
|
||||
if available, ok := event.Payload.(bool); ok {
|
||||
if available {
|
||||
item.Stock = StockStatus(1) // assuming 1 is in stock
|
||||
} else {
|
||||
item.Stock = StockStatus(0) // out of stock
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
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) UpdateSubscriptions(pubsub *actor.PubSub) {
|
||||
pubsub.UnsubscribeAll(c.GetId())
|
||||
skuSet := make(map[string]bool)
|
||||
for _, item := range c.Items {
|
||||
skuSet[item.Sku] = true
|
||||
}
|
||||
for sku := range skuSet {
|
||||
pubsub.Subscribe("inventory:"+sku, c.GetId())
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CartGrain) GetState() ([]byte, error) {
|
||||
return json.Marshal(c)
|
||||
@@ -240,27 +290,36 @@ func (c *CartGrain) UpdateTotals() {
|
||||
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)
|
||||
diff.Multiply(int64(item.Quantity))
|
||||
//rowTotal.Subtract(*diff)
|
||||
item.Discount = diff
|
||||
if diff.IncVat > 0 {
|
||||
c.TotalDiscount.Add(*diff)
|
||||
}
|
||||
}
|
||||
|
||||
item.TotalPrice = *rowTotal
|
||||
|
||||
c.TotalPrice.Add(*rowTotal)
|
||||
|
||||
}
|
||||
for _, delivery := range c.Deliveries {
|
||||
c.TotalPrice.Add(delivery.Price)
|
||||
}
|
||||
for _, voucher := range c.Vouchers {
|
||||
if _, ok := voucher.AppliesTo(c); ok {
|
||||
_, ok := voucher.AppliesTo(c)
|
||||
voucher.Applied = false
|
||||
if ok {
|
||||
value := NewPriceFromIncVat(voucher.Value, 25)
|
||||
|
||||
if c.TotalPrice.IncVat <= value.IncVat {
|
||||
// don't apply discounts to more than the total price
|
||||
continue
|
||||
}
|
||||
voucher.Applied = true
|
||||
c.TotalDiscount.Add(*value)
|
||||
c.TotalPrice.Subtract(*value)
|
||||
}
|
||||
57
pkg/cart/cart-mutation-helper.go
Normal file
57
pkg/cart/cart-mutation-helper.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
|
||||
func NewCartMultationRegistry() actor.MutationRegistry {
|
||||
|
||||
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{}
|
||||
}),
|
||||
actor.NewMutation(UpsertSubscriptionDetails, func() *messages.UpsertSubscriptionDetails {
|
||||
return &messages.UpsertSubscriptionDetails{}
|
||||
}),
|
||||
actor.NewMutation(InventoryReserved, func() *messages.InventoryReserved {
|
||||
return &messages.InventoryReserved{}
|
||||
}),
|
||||
actor.NewMutation(PreConditionFailed, func() *messages.PreConditionFailed {
|
||||
return &messages.PreConditionFailed{}
|
||||
}),
|
||||
)
|
||||
return reg
|
||||
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -34,8 +34,8 @@ func TestCartGrainUpdateTotalsBasic(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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)
|
||||
if c.TotalDiscount.IncVat != 500 {
|
||||
t.Fatalf("TotalDiscount expected 500 got %d", c.TotalDiscount.IncVat)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -28,9 +28,22 @@ func AddItem(g *CartGrain, m *messages.AddItem) error {
|
||||
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 {
|
||||
// Merge with any existing item having same SKU and matching StoreId (including both nil).
|
||||
for _, existing := range g.Items {
|
||||
if existing.Sku != m.Sku {
|
||||
continue
|
||||
}
|
||||
sameStore := (existing.StoreId == nil && m.StoreId == nil) ||
|
||||
(existing.StoreId != nil && m.StoreId != nil && *existing.StoreId == *m.StoreId)
|
||||
if !sameStore {
|
||||
continue
|
||||
}
|
||||
existing.Quantity += int(m.Quantity)
|
||||
existing.Stock = StockStatus(m.Stock)
|
||||
// If existing had nil store but new has one, adopt it.
|
||||
if existing.StoreId == nil && m.StoreId != nil {
|
||||
existing.StoreId = m.StoreId
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -50,6 +63,7 @@ func AddItem(g *CartGrain, m *messages.AddItem) error {
|
||||
ItemId: uint32(m.ItemId),
|
||||
Quantity: int(m.Quantity),
|
||||
Sku: m.Sku,
|
||||
Tax: int(taxRate * 100),
|
||||
Meta: &ItemMeta{
|
||||
Name: m.Name,
|
||||
Image: m.Image,
|
||||
@@ -63,6 +77,8 @@ func AddItem(g *CartGrain, m *messages.AddItem) error {
|
||||
SellerId: m.SellerId,
|
||||
SellerName: m.SellerName,
|
||||
},
|
||||
SaleStatus: m.SaleStatus,
|
||||
ParentId: m.ParentId,
|
||||
|
||||
Price: *pricePerItem,
|
||||
TotalPrice: *MultiplyPrice(*pricePerItem, int64(m.Quantity)),
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"slices"
|
||||
@@ -55,6 +55,8 @@ func AddVoucher(g *CartGrain, m *messages.AddVoucher) error {
|
||||
g.lastVoucherId++
|
||||
g.Vouchers = append(g.Vouchers, &Voucher{
|
||||
Id: g.lastVoucherId,
|
||||
Applied: false,
|
||||
Description: m.Description,
|
||||
Code: m.Code,
|
||||
Rules: m.VoucherRules,
|
||||
Value: m.Value,
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -45,6 +45,7 @@ func ChangeQuantity(g *CartGrain, m *messages.ChangeQuantity) error {
|
||||
if m.Quantity <= 0 {
|
||||
// Remove the item
|
||||
g.Items = append(g.Items[:foundIndex], g.Items[foundIndex+1:]...)
|
||||
g.UpdateTotals()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
8
pkg/cart/mutation_inventory_reserved.go
Normal file
8
pkg/cart/mutation_inventory_reserved.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package cart
|
||||
|
||||
import "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
|
||||
func InventoryReserved(g *CartGrain, m *messages.InventoryReserved) error {
|
||||
g.InventoryReserved = true
|
||||
return nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
7
pkg/cart/mutation_precondition.go
Normal file
7
pkg/cart/mutation_precondition.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package cart
|
||||
|
||||
import messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
|
||||
func PreConditionFailed(g *CartGrain, m *messages.PreConditionFailed) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
544
pkg/cart/mutation_test.go
Normal file
544
pkg/cart/mutation_test.go
Normal file
@@ -0,0 +1,544 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gogo/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/anypb"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
|
||||
// ----------------------
|
||||
// Helper constructors
|
||||
// ----------------------
|
||||
|
||||
func newTestGrain() *CartGrain {
|
||||
return NewCartGrain(123, time.Now())
|
||||
}
|
||||
|
||||
func newRegistry() actor.MutationRegistry {
|
||||
return NewCartMultationRegistry()
|
||||
}
|
||||
|
||||
func msgAddItem(sku string, price int64, qty int32, storePtr *string) *messages.AddItem {
|
||||
return &messages.AddItem{
|
||||
Sku: sku,
|
||||
Price: price,
|
||||
Quantity: qty,
|
||||
// Tax left 0 -> handler uses default 25%
|
||||
StoreId: storePtr,
|
||||
}
|
||||
}
|
||||
|
||||
func msgChangeQty(id uint32, qty int32) *messages.ChangeQuantity {
|
||||
return &messages.ChangeQuantity{Id: id, Quantity: qty}
|
||||
}
|
||||
|
||||
func msgRemoveItem(id uint32) *messages.RemoveItem {
|
||||
return &messages.RemoveItem{Id: id}
|
||||
}
|
||||
|
||||
func msgSetDelivery(provider string, items ...uint32) *messages.SetDelivery {
|
||||
uitems := make([]uint32, len(items))
|
||||
copy(uitems, items)
|
||||
return &messages.SetDelivery{Provider: provider, Items: uitems}
|
||||
}
|
||||
|
||||
func msgSetPickupPoint(deliveryId uint32, id string) *messages.SetPickupPoint {
|
||||
return &messages.SetPickupPoint{
|
||||
DeliveryId: deliveryId,
|
||||
Id: id,
|
||||
Name: ptr("Pickup"),
|
||||
Address: ptr("Street 1"),
|
||||
City: ptr("Town"),
|
||||
Zip: ptr("12345"),
|
||||
Country: ptr("SE"),
|
||||
}
|
||||
}
|
||||
|
||||
func msgClearCart() *messages.ClearCartRequest {
|
||||
return &messages.ClearCartRequest{}
|
||||
}
|
||||
|
||||
func msgAddVoucher(code string, value int64, rules ...string) *messages.AddVoucher {
|
||||
return &messages.AddVoucher{Code: code, Value: value, VoucherRules: rules}
|
||||
}
|
||||
|
||||
func msgRemoveVoucher(id uint32) *messages.RemoveVoucher {
|
||||
return &messages.RemoveVoucher{Id: id}
|
||||
}
|
||||
|
||||
func msgInitializeCheckout(orderId, status string, inProgress bool) *messages.InitializeCheckout {
|
||||
return &messages.InitializeCheckout{OrderId: orderId, Status: status, PaymentInProgress: inProgress}
|
||||
}
|
||||
|
||||
func msgOrderCreated(orderId, status string) *messages.OrderCreated {
|
||||
return &messages.OrderCreated{OrderId: orderId, Status: status}
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
// ----------------------
|
||||
// Apply helpers
|
||||
// ----------------------
|
||||
|
||||
func applyOne(t *testing.T, reg actor.MutationRegistry, g *CartGrain, msg proto.Message) actor.ApplyResult {
|
||||
t.Helper()
|
||||
results, err := reg.Apply(context.Background(), g, msg)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected registry-level error applying %T: %v", msg, err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected exactly one ApplyResult, got %d", len(results))
|
||||
}
|
||||
return results[0]
|
||||
}
|
||||
|
||||
// Expect success (nil error inside ApplyResult).
|
||||
func applyOK(t *testing.T, reg actor.MutationRegistry, g *CartGrain, msg proto.Message) {
|
||||
t.Helper()
|
||||
res := applyOne(t, reg, g, msg)
|
||||
if res.Error != nil {
|
||||
t.Fatalf("expected mutation %s (%T) to succeed, got error: %v", res.Type, msg, res.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// Expect an error matching substring.
|
||||
func applyErrorContains(t *testing.T, reg actor.MutationRegistry, g *CartGrain, msg proto.Message, substr string) {
|
||||
t.Helper()
|
||||
res := applyOne(t, reg, g, msg)
|
||||
if res.Error == nil {
|
||||
t.Fatalf("expected error applying %T, got nil", msg)
|
||||
}
|
||||
if substr != "" && !strings.Contains(res.Error.Error(), substr) {
|
||||
t.Fatalf("error mismatch, want substring %q got %q", substr, res.Error.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------
|
||||
// Tests
|
||||
// ----------------------
|
||||
|
||||
func TestMutationRegistryCoverage(t *testing.T) {
|
||||
reg := newRegistry()
|
||||
|
||||
expected := []string{
|
||||
"AddItem",
|
||||
"ChangeQuantity",
|
||||
"RemoveItem",
|
||||
"InitializeCheckout",
|
||||
"OrderCreated",
|
||||
"RemoveDelivery",
|
||||
"SetDelivery",
|
||||
"SetPickupPoint",
|
||||
"ClearCartRequest",
|
||||
"AddVoucher",
|
||||
"RemoveVoucher",
|
||||
"UpsertSubscriptionDetails",
|
||||
}
|
||||
|
||||
names := reg.(*actor.ProtoMutationRegistry).RegisteredMutations()
|
||||
for _, want := range expected {
|
||||
if !slices.Contains(names, want) {
|
||||
t.Fatalf("registry missing mutation %s; got %v", want, names)
|
||||
}
|
||||
}
|
||||
|
||||
// Create() by name returns correct concrete type.
|
||||
for _, name := range expected {
|
||||
msg, ok := reg.Create(name)
|
||||
if !ok {
|
||||
t.Fatalf("Create failed for %s", name)
|
||||
}
|
||||
rt := reflect.TypeOf(msg)
|
||||
if rt.Kind() == reflect.Ptr {
|
||||
rt = rt.Elem()
|
||||
}
|
||||
if rt.Name() != name {
|
||||
t.Fatalf("Create(%s) returned wrong type %s", name, rt.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// Unregistered create
|
||||
if m, ok := reg.Create("DoesNotExist"); ok || m != nil {
|
||||
t.Fatalf("Create should fail for unknown; got (%T,%v)", m, ok)
|
||||
}
|
||||
|
||||
// GetTypeName sanity
|
||||
add := &messages.AddItem{}
|
||||
nm, ok := reg.GetTypeName(add)
|
||||
if !ok || nm != "AddItem" {
|
||||
t.Fatalf("GetTypeName failed for AddItem, got (%q,%v)", nm, ok)
|
||||
}
|
||||
|
||||
// Apply unregistered message -> result should contain ErrMutationNotRegistered, no top-level error
|
||||
results, err := reg.Apply(context.Background(), newTestGrain(), &messages.Noop{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected top-level error applying unregistered mutation: %v", err)
|
||||
}
|
||||
if len(results) != 1 || results[0].Error == nil || results[0].Error != actor.ErrMutationNotRegistered {
|
||||
t.Fatalf("expected ApplyResult with ErrMutationNotRegistered, got %#v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddItemAndMerging(t *testing.T) {
|
||||
reg := newRegistry()
|
||||
g := newTestGrain()
|
||||
|
||||
// Merge scenario (same SKU + same store pointer)
|
||||
add1 := msgAddItem("SKU-1", 1000, 2, nil)
|
||||
applyOK(t, reg, g, add1)
|
||||
|
||||
if len(g.Items) != 1 || g.Items[0].Quantity != 2 {
|
||||
t.Fatalf("expected first item added; items=%d qty=%d", len(g.Items), g.Items[0].Quantity)
|
||||
}
|
||||
|
||||
applyOK(t, reg, g, msgAddItem("SKU-1", 1000, 3, nil)) // should merge
|
||||
if len(g.Items) != 1 || g.Items[0].Quantity != 5 {
|
||||
t.Fatalf("expected merge quantity=5 items=%d qty=%d", len(g.Items), g.Items[0].Quantity)
|
||||
}
|
||||
|
||||
// Different store pointer -> new line
|
||||
store := "S1"
|
||||
applyOK(t, reg, g, msgAddItem("SKU-1", 1000, 1, &store))
|
||||
if len(g.Items) != 2 {
|
||||
t.Fatalf("expected second line for different store pointer; items=%d", len(g.Items))
|
||||
}
|
||||
|
||||
// Same store pointer & SKU -> merge with second line
|
||||
applyOK(t, reg, g, msgAddItem("SKU-1", 1000, 4, &store))
|
||||
if len(g.Items) != 2 || g.Items[1].Quantity != 5 {
|
||||
t.Fatalf("expected merge on second line; items=%d second.qty=%d", len(g.Items), g.Items[1].Quantity)
|
||||
}
|
||||
|
||||
// Invalid quantity
|
||||
applyErrorContains(t, reg, g, msgAddItem("BAD", 1000, 0, nil), "invalid quantity")
|
||||
}
|
||||
|
||||
func TestChangeQuantityBehavior(t *testing.T) {
|
||||
reg := newRegistry()
|
||||
g := newTestGrain()
|
||||
|
||||
applyOK(t, reg, g, msgAddItem("A", 1500, 2, nil))
|
||||
id := g.Items[0].Id
|
||||
|
||||
// Increase quantity
|
||||
applyOK(t, reg, g, msgChangeQty(id, 5))
|
||||
if g.Items[0].Quantity != 5 {
|
||||
t.Fatalf("quantity not updated expected=5 got=%d", g.Items[0].Quantity)
|
||||
}
|
||||
|
||||
// Remove item by setting <=0
|
||||
applyOK(t, reg, g, msgChangeQty(id, 0))
|
||||
if len(g.Items) != 0 {
|
||||
t.Fatalf("expected item removed; items=%d", len(g.Items))
|
||||
}
|
||||
|
||||
// Not found
|
||||
applyErrorContains(t, reg, g, msgChangeQty(9999, 1), "not found")
|
||||
}
|
||||
|
||||
func TestRemoveItemBehavior(t *testing.T) {
|
||||
reg := newRegistry()
|
||||
g := newTestGrain()
|
||||
|
||||
applyOK(t, reg, g, msgAddItem("X", 1200, 1, nil))
|
||||
id := g.Items[0].Id
|
||||
|
||||
applyOK(t, reg, g, msgRemoveItem(id))
|
||||
if len(g.Items) != 0 {
|
||||
t.Fatalf("expected item removed; items=%d", len(g.Items))
|
||||
}
|
||||
|
||||
applyErrorContains(t, reg, g, msgRemoveItem(id), "not found")
|
||||
}
|
||||
|
||||
func TestDeliveryMutations(t *testing.T) {
|
||||
reg := newRegistry()
|
||||
g := newTestGrain()
|
||||
|
||||
applyOK(t, reg, g, msgAddItem("D1", 1000, 1, nil))
|
||||
applyOK(t, reg, g, msgAddItem("D2", 2000, 1, nil))
|
||||
i1 := g.Items[0].Id
|
||||
|
||||
// Explicit items
|
||||
applyOK(t, reg, g, msgSetDelivery("POSTNORD", i1))
|
||||
if len(g.Deliveries) != 1 || len(g.Deliveries[0].Items) != 1 || g.Deliveries[0].Items[0] != i1 {
|
||||
t.Fatalf("delivery not created as expected: %+v", g.Deliveries)
|
||||
}
|
||||
|
||||
// Attempt to attach an already-delivered item
|
||||
applyErrorContains(t, reg, g, msgSetDelivery("POSTNORD", i1), "already has a delivery")
|
||||
|
||||
// Attach remaining item via empty list (auto include items without delivery)
|
||||
applyOK(t, reg, g, msgSetDelivery("DHL"))
|
||||
if len(g.Deliveries) != 2 {
|
||||
t.Fatalf("expected second delivery; deliveries=%d", len(g.Deliveries))
|
||||
}
|
||||
|
||||
// Non-existent item
|
||||
applyErrorContains(t, reg, g, msgSetDelivery("UPS", 99999), "not found")
|
||||
|
||||
// No eligible items left
|
||||
applyErrorContains(t, reg, g, msgSetDelivery("UPS"), "no eligible items")
|
||||
|
||||
// Set pickup point on first delivery
|
||||
did := g.Deliveries[0].Id
|
||||
applyOK(t, reg, g, msgSetPickupPoint(did, "PP1"))
|
||||
if g.Deliveries[0].PickupPoint == nil || g.Deliveries[0].PickupPoint.Id != "PP1" {
|
||||
t.Fatalf("pickup point not set correctly: %+v", g.Deliveries[0].PickupPoint)
|
||||
}
|
||||
|
||||
// Bad delivery id
|
||||
applyErrorContains(t, reg, g, msgSetPickupPoint(9999, "PPX"), "delivery id")
|
||||
|
||||
// Remove delivery
|
||||
applyOK(t, reg, g, &messages.RemoveDelivery{Id: did})
|
||||
if len(g.Deliveries) != 1 || g.Deliveries[0].Id == did {
|
||||
t.Fatalf("expected first delivery removed, remaining: %+v", g.Deliveries)
|
||||
}
|
||||
|
||||
// Remove delivery not found
|
||||
applyErrorContains(t, reg, g, &messages.RemoveDelivery{Id: did}, "not found")
|
||||
}
|
||||
|
||||
func TestClearCart(t *testing.T) {
|
||||
reg := newRegistry()
|
||||
g := newTestGrain()
|
||||
|
||||
applyOK(t, reg, g, msgAddItem("X", 1000, 2, nil))
|
||||
applyOK(t, reg, g, msgSetDelivery("P", g.Items[0].Id))
|
||||
|
||||
applyOK(t, reg, g, msgClearCart())
|
||||
|
||||
if len(g.Items) != 0 || len(g.Deliveries) != 0 {
|
||||
t.Fatalf("expected cart cleared; items=%d deliveries=%d", len(g.Items), len(g.Deliveries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoucherMutations(t *testing.T) {
|
||||
reg := newRegistry()
|
||||
g := newTestGrain()
|
||||
|
||||
applyOK(t, reg, g, msgAddItem("VOUCH", 10000, 1, nil))
|
||||
applyOK(t, reg, g, msgAddVoucher("PROMO", 5000))
|
||||
|
||||
if len(g.Vouchers) != 1 {
|
||||
t.Fatalf("voucher not stored")
|
||||
}
|
||||
if g.TotalDiscount.IncVat != 5000 {
|
||||
t.Fatalf("expected discount 5000 got %d", g.TotalDiscount.IncVat)
|
||||
}
|
||||
if g.TotalPrice.IncVat != 5000 {
|
||||
t.Fatalf("expected total price 5000 got %d", g.TotalPrice.IncVat)
|
||||
}
|
||||
|
||||
// Duplicate voucher code
|
||||
applyErrorContains(t, reg, g, msgAddVoucher("PROMO", 1000), "already applied")
|
||||
|
||||
// Add a large voucher (should not apply because value > total price)
|
||||
applyOK(t, reg, g, msgAddVoucher("BIG", 100000))
|
||||
if len(g.Vouchers) != 2 {
|
||||
t.Fatalf("expected second voucher stored")
|
||||
}
|
||||
if g.TotalDiscount.IncVat != 5000 || g.TotalPrice.IncVat != 5000 {
|
||||
t.Fatalf("large voucher incorrectly applied discount=%d total=%d",
|
||||
g.TotalDiscount.IncVat, g.TotalPrice.IncVat)
|
||||
}
|
||||
|
||||
// Remove existing voucher
|
||||
firstId := g.Vouchers[0].Id
|
||||
applyOK(t, reg, g, msgRemoveVoucher(firstId))
|
||||
|
||||
if slices.ContainsFunc(g.Vouchers, func(v *Voucher) bool { return v.Id == firstId }) {
|
||||
t.Fatalf("voucher id %d not removed", firstId)
|
||||
}
|
||||
// After removing PROMO, BIG remains but is not applied (exceeds price)
|
||||
if g.TotalDiscount.IncVat != 0 || g.TotalPrice.IncVat != 10000 {
|
||||
t.Fatalf("totals incorrect after removal discount=%d total=%d",
|
||||
g.TotalDiscount.IncVat, g.TotalPrice.IncVat)
|
||||
}
|
||||
|
||||
// Remove not applied
|
||||
applyErrorContains(t, reg, g, msgRemoveVoucher(firstId), "not applied")
|
||||
}
|
||||
|
||||
func TestCheckoutMutations(t *testing.T) {
|
||||
reg := newRegistry()
|
||||
g := newTestGrain()
|
||||
|
||||
applyOK(t, reg, g, msgInitializeCheckout("ORD-1", "PENDING", true))
|
||||
if g.OrderReference != "ORD-1" || g.PaymentStatus != "PENDING" || !g.PaymentInProgress {
|
||||
t.Fatalf("initialize checkout failed: ref=%s status=%s inProgress=%v",
|
||||
g.OrderReference, g.PaymentStatus, g.PaymentInProgress)
|
||||
}
|
||||
|
||||
applyOK(t, reg, g, msgOrderCreated("ORD-1", "COMPLETED"))
|
||||
if g.OrderReference != "ORD-1" || g.PaymentStatus != "COMPLETED" || g.PaymentInProgress {
|
||||
t.Fatalf("order created mutation failed: ref=%s status=%s inProgress=%v",
|
||||
g.OrderReference, g.PaymentStatus, g.PaymentInProgress)
|
||||
}
|
||||
|
||||
applyErrorContains(t, reg, g, msgInitializeCheckout("", "X", true), "missing orderId")
|
||||
applyErrorContains(t, reg, g, msgOrderCreated("", "X"), "missing orderId")
|
||||
}
|
||||
|
||||
func TestSubscriptionDetailsMutation(t *testing.T) {
|
||||
reg := newRegistry()
|
||||
g := newTestGrain()
|
||||
|
||||
// Upsert new (Id == nil)
|
||||
msgNew := &messages.UpsertSubscriptionDetails{
|
||||
OfferingCode: "OFF1",
|
||||
SigningType: "TYPE1",
|
||||
}
|
||||
applyOK(t, reg, g, msgNew)
|
||||
if len(g.SubscriptionDetails) != 1 {
|
||||
t.Fatalf("expected one subscription detail; got=%d", len(g.SubscriptionDetails))
|
||||
}
|
||||
|
||||
// Capture created id
|
||||
var createdId string
|
||||
for k := range g.SubscriptionDetails {
|
||||
createdId = k
|
||||
}
|
||||
|
||||
// Update existing
|
||||
msgUpdate := &messages.UpsertSubscriptionDetails{
|
||||
Id: &createdId,
|
||||
OfferingCode: "OFF2",
|
||||
SigningType: "TYPE2",
|
||||
}
|
||||
applyOK(t, reg, g, msgUpdate)
|
||||
if g.SubscriptionDetails[createdId].OfferingCode != "OFF2" ||
|
||||
g.SubscriptionDetails[createdId].SigningType != "TYPE2" {
|
||||
t.Fatalf("subscription details not updated: %+v", g.SubscriptionDetails[createdId])
|
||||
}
|
||||
|
||||
// Update non-existent
|
||||
badId := "NON_EXISTENT"
|
||||
applyErrorContains(t, reg, g, &messages.UpsertSubscriptionDetails{Id: &badId}, "not found")
|
||||
|
||||
// Nil mutation should be ignored and produce zero results.
|
||||
resultsNil, errNil := reg.Apply(context.Background(), g, (*messages.UpsertSubscriptionDetails)(nil))
|
||||
if errNil != nil {
|
||||
t.Fatalf("unexpected error for nil mutation element: %v", errNil)
|
||||
}
|
||||
if len(resultsNil) != 0 {
|
||||
t.Fatalf("expected zero results for nil mutation, got %d", len(resultsNil))
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure registry Apply handles nil grain and nil message defensive errors consistently.
|
||||
func TestRegistryDefensiveErrors(t *testing.T) {
|
||||
reg := newRegistry()
|
||||
g := newTestGrain()
|
||||
|
||||
// Nil grain
|
||||
results, err := reg.Apply(context.Background(), nil, &messages.AddItem{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for nil grain")
|
||||
}
|
||||
if len(results) != 0 {
|
||||
t.Fatalf("expected no results for nil grain")
|
||||
}
|
||||
|
||||
// Nil message slice
|
||||
results, _ = reg.Apply(context.Background(), g, nil)
|
||||
|
||||
if len(results) != 0 {
|
||||
t.Fatalf("expected no results when message slice nil")
|
||||
}
|
||||
}
|
||||
|
||||
type SubscriptionDetailsRequest struct {
|
||||
Id *string `json:"id,omitempty"`
|
||||
OfferingCode string `json:"offeringCode,omitempty"`
|
||||
SigningType string `json:"signingType,omitempty"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func (sd *SubscriptionDetailsRequest) ToMessage() *messages.UpsertSubscriptionDetails {
|
||||
return &messages.UpsertSubscriptionDetails{
|
||||
Id: sd.Id,
|
||||
OfferingCode: sd.OfferingCode,
|
||||
SigningType: sd.SigningType,
|
||||
Data: &anypb.Any{Value: sd.Data},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionDetailsJSONValidation(t *testing.T) {
|
||||
reg := newRegistry()
|
||||
g := newTestGrain()
|
||||
|
||||
// Valid JSON on create
|
||||
jsonStr := `{"offeringCode": "OFFJSON", "signingType": "TYPEJSON", "data": {"value":"test","a":1}}`
|
||||
var validCreate SubscriptionDetailsRequest
|
||||
if err := json.Unmarshal([]byte(jsonStr), &validCreate); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applyOK(t, reg, g, validCreate.ToMessage())
|
||||
if len(g.SubscriptionDetails) != 1 {
|
||||
t.Fatalf("expected one subscription detail after valid create, got %d", len(g.SubscriptionDetails))
|
||||
}
|
||||
var id string
|
||||
for k := range g.SubscriptionDetails {
|
||||
id = k
|
||||
}
|
||||
if string(g.SubscriptionDetails[id].Meta) != `{"value":"test","a":1}` {
|
||||
t.Fatalf("expected meta stored as valid json, got %s", string(g.SubscriptionDetails[id].Meta))
|
||||
}
|
||||
|
||||
// Update with valid JSON replaces meta
|
||||
jsonStr2 := fmt.Sprintf(`{"id": "%s", "data": {"value": "eyJjaGFuZ2VkIjoxMjN9"}}`, id)
|
||||
var updateValid messages.UpsertSubscriptionDetails
|
||||
if err := json.Unmarshal([]byte(jsonStr2), &updateValid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applyOK(t, reg, g, &updateValid)
|
||||
if string(g.SubscriptionDetails[id].Meta) != `{"changed":123}` {
|
||||
t.Fatalf("expected meta updated to new json, got %s", string(g.SubscriptionDetails[id].Meta))
|
||||
}
|
||||
|
||||
// Invalid JSON on create
|
||||
jsonStr3 := `{"offeringCode": "BAD", "signingType": "TYPE", "data": {"value": "eyJicm9rZW4iO30="}}`
|
||||
var invalidCreate messages.UpsertSubscriptionDetails
|
||||
if err := json.Unmarshal([]byte(jsonStr3), &invalidCreate); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := applyOne(t, reg, g, &invalidCreate)
|
||||
if res.Error == nil || !strings.Contains(res.Error.Error(), "invalid json") {
|
||||
t.Fatalf("expected invalid json error on create, got %v", res.Error)
|
||||
}
|
||||
|
||||
// Invalid JSON on update
|
||||
jsonStr4 := fmt.Sprintf(`{"id": "%s", "data": {"value": "e29vcHM="}}`, id)
|
||||
var badUpdate messages.UpsertSubscriptionDetails
|
||||
if err := json.Unmarshal([]byte(jsonStr4), &badUpdate); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res2 := applyOne(t, reg, g, &badUpdate)
|
||||
if res2.Error == nil || !strings.Contains(res2.Error.Error(), "invalid json") {
|
||||
t.Fatalf("expected invalid json error on update, got %v", res2.Error)
|
||||
}
|
||||
|
||||
// Empty Data should not overwrite existing meta
|
||||
jsonStr5 := fmt.Sprintf(`{"id": "%s"}`, id)
|
||||
var emptyUpdate messages.UpsertSubscriptionDetails
|
||||
if err := json.Unmarshal([]byte(jsonStr5), &emptyUpdate); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applyOK(t, reg, g, &emptyUpdate)
|
||||
if string(g.SubscriptionDetails[id].Meta) != `{"changed":123}` {
|
||||
t.Fatalf("empty update should not change meta, got %s", string(g.SubscriptionDetails[id].Meta))
|
||||
}
|
||||
}
|
||||
69
pkg/cart/mutation_upsert_subscriptiondetails.go
Normal file
69
pkg/cart/mutation_upsert_subscriptiondetails.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
|
||||
func UpsertSubscriptionDetails(g *CartGrain, m *messages.UpsertSubscriptionDetails) error {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
metaBytes := m.Data.GetValue()
|
||||
|
||||
// Create new subscription details when Id is nil.
|
||||
if m.Id == nil {
|
||||
// Validate JSON if provided.
|
||||
var meta json.RawMessage
|
||||
if metaBytes != nil {
|
||||
if !json.Valid(metaBytes) {
|
||||
return fmt.Errorf("subscription details invalid json")
|
||||
}
|
||||
meta = json.RawMessage(metaBytes)
|
||||
}
|
||||
|
||||
id := MustNewCartId().String()
|
||||
|
||||
g.SubscriptionDetails[id] = &SubscriptionDetails{
|
||||
Id: id,
|
||||
Version: 1,
|
||||
OfferingCode: m.OfferingCode,
|
||||
SigningType: m.SigningType,
|
||||
Meta: meta,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update existing entry.
|
||||
existing, ok := g.SubscriptionDetails[*m.Id]
|
||||
if !ok {
|
||||
n := &SubscriptionDetails{
|
||||
Id: *m.Id,
|
||||
Version: 0,
|
||||
}
|
||||
g.SubscriptionDetails[*m.Id] = n
|
||||
existing = n
|
||||
}
|
||||
changed := false
|
||||
if m.OfferingCode != "" {
|
||||
existing.OfferingCode = m.OfferingCode
|
||||
changed = true
|
||||
}
|
||||
if m.SigningType != "" {
|
||||
existing.SigningType = m.SigningType
|
||||
changed = true
|
||||
}
|
||||
if metaBytes != nil {
|
||||
if !json.Valid(metaBytes) {
|
||||
return fmt.Errorf("subscription details invalid json")
|
||||
}
|
||||
existing.Meta = json.RawMessage(metaBytes)
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
existing.Version++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -33,14 +33,14 @@ func TestPriceMarshalJSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewPriceFromIncVat(t *testing.T) {
|
||||
p := NewPriceFromIncVat(1000, 0.25)
|
||||
if p.IncVat != 1000 {
|
||||
t.Fatalf("expected IncVat %d got %d", 1000, p.IncVat)
|
||||
p := NewPriceFromIncVat(1250, 25)
|
||||
if p.IncVat != 1250 {
|
||||
t.Fatalf("expected IncVat %d got %d", 1250, p.IncVat)
|
||||
}
|
||||
if p.VatRates[25] != 250 {
|
||||
t.Fatalf("expected VAT 25 rate %d got %d", 250, p.VatRates[25])
|
||||
}
|
||||
if p.ValueExVat() != 750 {
|
||||
if p.ValueExVat() != 1000 {
|
||||
t.Fatalf("expected exVat %d got %d", 750, p.ValueExVat())
|
||||
}
|
||||
}
|
||||
@@ -133,3 +133,106 @@ func TestPriceMultiplyMethod(t *testing.T) {
|
||||
t.Fatalf("expected exVat %d got %d", exBefore*2, p.ValueExVat())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTaxAmount(t *testing.T) {
|
||||
tests := []struct {
|
||||
total int64
|
||||
tax int
|
||||
expected int64
|
||||
desc string
|
||||
}{
|
||||
{1250, 2500, 250, "25% VAT"}, // 1250 / (1 + 100/25) = 1250 / 5 = 250
|
||||
{1000, 2000, 166, "20% VAT"}, // 1000 / (1 + 100/20) = 1000 / 6 ≈ 166
|
||||
{1200, 2500, 240, "25% VAT on 1200"},
|
||||
{0, 2500, 0, "zero total"},
|
||||
{100, 1000, 9, "10% VAT"}, // tax=1000 for 10%, 100 / (1 + 100/10) = 100 / 11 ≈ 9
|
||||
{100, 10000, 50, "100% VAT"}, // tax=10000 for 100%, 100 / (1 + 100/100) = 100 / 2 = 50
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := GetTaxAmount(tt.total, tt.tax)
|
||||
if result != tt.expected {
|
||||
t.Errorf("GetTaxAmount(%d, %d) [%s] = %d; expected %d", tt.total, tt.tax, tt.desc, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPriceFromIncVatEdgeCases(t *testing.T) {
|
||||
// Zero VAT rate
|
||||
p := NewPriceFromIncVat(1000, 0)
|
||||
if p.IncVat != 1000 {
|
||||
t.Errorf("expected IncVat 1000, got %d", p.IncVat)
|
||||
}
|
||||
if len(p.VatRates) != 1 || p.VatRates[0] != 0 {
|
||||
t.Errorf("expected VAT 0 for rate 0, got %v", p.VatRates)
|
||||
}
|
||||
if p.ValueExVat() != 1000 {
|
||||
t.Errorf("expected exVat 1000, got %d", p.ValueExVat())
|
||||
}
|
||||
|
||||
// High VAT rate, e.g., 50%
|
||||
p = NewPriceFromIncVat(1500, 50)
|
||||
expectedVat := int64(1500 / (1 + 100/50)) // 1500 / 3 = 500
|
||||
if p.VatRates[50] != expectedVat {
|
||||
t.Errorf("expected VAT %d for 50%%, got %d", expectedVat, p.VatRates[50])
|
||||
}
|
||||
if p.ValueExVat() != 1500-expectedVat {
|
||||
t.Errorf("expected exVat %d, got %d", 1500-expectedVat, p.ValueExVat())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceValueExVatAndTotalVat(t *testing.T) {
|
||||
p := Price{IncVat: 13700, VatRates: map[float32]int64{25: 2500, 12: 1200}}
|
||||
exVat := p.ValueExVat()
|
||||
totalVat := p.TotalVat()
|
||||
if exVat != 10000 {
|
||||
t.Errorf("expected exVat 10000, got %d", exVat)
|
||||
}
|
||||
if totalVat != 3700 {
|
||||
t.Errorf("expected totalVat 3700, got %d", totalVat)
|
||||
}
|
||||
if exVat+totalVat != p.IncVat {
|
||||
t.Errorf("exVat + totalVat should equal IncVat: %d + %d != %d", exVat, totalVat, p.IncVat)
|
||||
}
|
||||
|
||||
// Empty VAT rates
|
||||
p2 := Price{IncVat: 500, VatRates: nil}
|
||||
if p2.ValueExVat() != 500 {
|
||||
t.Errorf("expected exVat 500 for no VAT, got %d", p2.ValueExVat())
|
||||
}
|
||||
if p2.TotalVat() != 0 {
|
||||
t.Errorf("expected totalVat 0, got %d", p2.TotalVat())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiplyPriceWithZeroQty(t *testing.T) {
|
||||
base := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}}
|
||||
multiplied := MultiplyPrice(base, 0)
|
||||
if multiplied.IncVat != 0 {
|
||||
t.Errorf("expected IncVat 0, got %d", multiplied.IncVat)
|
||||
}
|
||||
if len(multiplied.VatRates) != 1 || multiplied.VatRates[25] != 0 {
|
||||
t.Errorf("expected VAT 0, got %v", multiplied.VatRates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceAddSubtractEdgeCases(t *testing.T) {
|
||||
a := Price{IncVat: 1000, VatRates: map[float32]int64{25: 200}}
|
||||
b := Price{IncVat: 500, VatRates: map[float32]int64{12: 54}} // Different rate
|
||||
|
||||
acc := NewPrice()
|
||||
acc.Add(a)
|
||||
acc.Add(b)
|
||||
|
||||
if acc.VatRates[25] != 200 || acc.VatRates[12] != 54 {
|
||||
t.Errorf("expected VAT 25:200, 12:54, got %v", acc.VatRates)
|
||||
}
|
||||
|
||||
// Subtract more than added (negative VAT)
|
||||
acc.Subtract(a)
|
||||
acc.Subtract(b)
|
||||
acc.Subtract(a) // Subtract extra a
|
||||
if acc.VatRates[25] != -200 || acc.VatRates[12] != 0 {
|
||||
t.Errorf("expected negative VAT for 25 after over-subtract, got %v", acc.VatRates)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
v1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -33,13 +35,10 @@ func (k *K8sDiscovery) DiscoverInNamespace(namespace string) ([]string, error) {
|
||||
return hosts, nil
|
||||
}
|
||||
|
||||
type HostChange struct {
|
||||
Host string
|
||||
Type watch.EventType
|
||||
}
|
||||
|
||||
func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
|
||||
timeout := int64(30)
|
||||
ipsThatAreReady := make(map[string]bool)
|
||||
m := sync.Mutex{}
|
||||
watcherFn := func(options metav1.ListOptions) (watch.Interface, error) {
|
||||
return k.client.CoreV1().Pods("").Watch(k.ctx, metav1.ListOptions{
|
||||
LabelSelector: "actor-pool=cart",
|
||||
@@ -55,10 +54,22 @@ func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
|
||||
for event := range watcher.ResultChan() {
|
||||
|
||||
pod := event.Object.(*v1.Pod)
|
||||
// log.Printf("pod change %+v", pod.Status.Phase == v1.PodRunning)
|
||||
isReady := slices.ContainsFunc(pod.Status.Conditions, func(condition v1.PodCondition) bool {
|
||||
return condition.Type == v1.PodReady && condition.Status == v1.ConditionTrue
|
||||
})
|
||||
m.Lock()
|
||||
oldState := ipsThatAreReady[pod.Status.PodIP]
|
||||
ipsThatAreReady[pod.Status.PodIP] = isReady
|
||||
m.Unlock()
|
||||
if oldState != isReady {
|
||||
ch <- HostChange{
|
||||
Host: pod.Status.PodIP,
|
||||
Type: event.Type,
|
||||
IsReady: isReady,
|
||||
}
|
||||
}
|
||||
ch <- HostChange{
|
||||
Host: pod.Status.PodIP,
|
||||
IsReady: isReady,
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -2,9 +2,8 @@ package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
)
|
||||
|
||||
// MockDiscovery is an in-memory Discovery implementation for tests.
|
||||
@@ -56,14 +55,12 @@ func (m *MockDiscovery) AddHost(host string) {
|
||||
if m.closed {
|
||||
return
|
||||
}
|
||||
for _, h := range m.hosts {
|
||||
if h == host {
|
||||
if slices.Contains(m.hosts, host) {
|
||||
return
|
||||
}
|
||||
}
|
||||
m.hosts = append(m.hosts, host)
|
||||
if m.started {
|
||||
m.events <- HostChange{Host: host, Type: watch.Added}
|
||||
m.events <- HostChange{Host: host, IsReady: true}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +83,7 @@ func (m *MockDiscovery) RemoveHost(host string) {
|
||||
}
|
||||
m.hosts = append(m.hosts[:idx], m.hosts[idx+1:]...)
|
||||
if m.started {
|
||||
m.events <- HostChange{Host: host, Type: watch.Deleted}
|
||||
m.events <- HostChange{Host: host, IsReady: false}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package discovery
|
||||
|
||||
type HostChange struct {
|
||||
Host string
|
||||
IsReady bool
|
||||
}
|
||||
|
||||
type Discovery interface {
|
||||
Discover() ([]string, error)
|
||||
Watch() (<-chan HostChange, error)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.5
|
||||
// protoc v5.29.3
|
||||
// protoc v6.33.1
|
||||
// source: control_plane.proto
|
||||
|
||||
package messages
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v5.29.3
|
||||
// - protoc v6.33.1
|
||||
// source: control_plane.proto
|
||||
|
||||
package messages
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.5
|
||||
// protoc v5.29.3
|
||||
// protoc v6.33.1
|
||||
// source: messages.proto
|
||||
|
||||
package messages
|
||||
@@ -9,6 +9,7 @@ package messages
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
anypb "google.golang.org/protobuf/types/known/anypb"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
unsafe "unsafe"
|
||||
@@ -79,6 +80,7 @@ type AddItem struct {
|
||||
SellerId string `protobuf:"bytes,19,opt,name=sellerId,proto3" json:"sellerId,omitempty"`
|
||||
SellerName string `protobuf:"bytes,20,opt,name=sellerName,proto3" json:"sellerName,omitempty"`
|
||||
Country string `protobuf:"bytes,21,opt,name=country,proto3" json:"country,omitempty"`
|
||||
SaleStatus string `protobuf:"bytes,24,opt,name=saleStatus,proto3" json:"saleStatus,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"`
|
||||
@@ -256,6 +258,13 @@ func (x *AddItem) GetCountry() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *AddItem) GetSaleStatus() string {
|
||||
if x != nil {
|
||||
return x.SaleStatus
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *AddItem) GetOutlet() string {
|
||||
if x != nil && x.Outlet != nil {
|
||||
return *x.Outlet
|
||||
@@ -917,30 +926,29 @@ func (x *InitializeCheckout) GetPaymentInProgress() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type VoucherRule struct {
|
||||
type InventoryReserved 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"`
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"`
|
||||
Message *string `protobuf:"bytes,3,opt,name=message,proto3,oneof" json:"message,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *VoucherRule) Reset() {
|
||||
*x = VoucherRule{}
|
||||
func (x *InventoryReserved) Reset() {
|
||||
*x = InventoryReserved{}
|
||||
mi := &file_messages_proto_msgTypes[12]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *VoucherRule) String() string {
|
||||
func (x *InventoryReserved) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*VoucherRule) ProtoMessage() {}
|
||||
func (*InventoryReserved) ProtoMessage() {}
|
||||
|
||||
func (x *VoucherRule) ProtoReflect() protoreflect.Message {
|
||||
func (x *InventoryReserved) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_messages_proto_msgTypes[12]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
@@ -952,35 +960,28 @@ func (x *VoucherRule) ProtoReflect() protoreflect.Message {
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use VoucherRule.ProtoReflect.Descriptor instead.
|
||||
func (*VoucherRule) Descriptor() ([]byte, []int) {
|
||||
// Deprecated: Use InventoryReserved.ProtoReflect.Descriptor instead.
|
||||
func (*InventoryReserved) Descriptor() ([]byte, []int) {
|
||||
return file_messages_proto_rawDescGZIP(), []int{12}
|
||||
}
|
||||
|
||||
func (x *VoucherRule) GetType() string {
|
||||
func (x *InventoryReserved) GetId() string {
|
||||
if x != nil {
|
||||
return x.Type
|
||||
return x.Id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *VoucherRule) GetDescription() string {
|
||||
func (x *InventoryReserved) GetStatus() string {
|
||||
if x != nil {
|
||||
return x.Description
|
||||
return x.Status
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *VoucherRule) GetCondition() string {
|
||||
if x != nil {
|
||||
return x.Condition
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *VoucherRule) GetAction() string {
|
||||
if x != nil {
|
||||
return x.Action
|
||||
func (x *InventoryReserved) GetMessage() string {
|
||||
if x != nil && x.Message != nil {
|
||||
return *x.Message
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -989,7 +990,8 @@ 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"`
|
||||
VoucherRules []string `protobuf:"bytes,3,rep,name=voucherRules,proto3" json:"voucherRules,omitempty"`
|
||||
Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -1038,13 +1040,20 @@ func (x *AddVoucher) GetValue() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *AddVoucher) GetVoucherRules() []*VoucherRule {
|
||||
func (x *AddVoucher) GetVoucherRules() []string {
|
||||
if x != nil {
|
||||
return x.VoucherRules
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *AddVoucher) GetDescription() string {
|
||||
if x != nil {
|
||||
return x.Description
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type RemoveVoucher struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
@@ -1089,152 +1098,301 @@ func (x *RemoveVoucher) GetId() uint32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
type UpsertSubscriptionDetails struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Id *string `protobuf:"bytes,1,opt,name=id,proto3,oneof" json:"id,omitempty"`
|
||||
OfferingCode string `protobuf:"bytes,2,opt,name=offeringCode,proto3" json:"offeringCode,omitempty"`
|
||||
SigningType string `protobuf:"bytes,3,opt,name=signingType,proto3" json:"signingType,omitempty"`
|
||||
Data *anypb.Any `protobuf:"bytes,4,opt,name=data,proto3" json:"data,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *UpsertSubscriptionDetails) Reset() {
|
||||
*x = UpsertSubscriptionDetails{}
|
||||
mi := &file_messages_proto_msgTypes[15]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *UpsertSubscriptionDetails) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*UpsertSubscriptionDetails) ProtoMessage() {}
|
||||
|
||||
func (x *UpsertSubscriptionDetails) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_messages_proto_msgTypes[15]
|
||||
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 UpsertSubscriptionDetails.ProtoReflect.Descriptor instead.
|
||||
func (*UpsertSubscriptionDetails) Descriptor() ([]byte, []int) {
|
||||
return file_messages_proto_rawDescGZIP(), []int{15}
|
||||
}
|
||||
|
||||
func (x *UpsertSubscriptionDetails) GetId() string {
|
||||
if x != nil && x.Id != nil {
|
||||
return *x.Id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *UpsertSubscriptionDetails) GetOfferingCode() string {
|
||||
if x != nil {
|
||||
return x.OfferingCode
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *UpsertSubscriptionDetails) GetSigningType() string {
|
||||
if x != nil {
|
||||
return x.SigningType
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *UpsertSubscriptionDetails) GetData() *anypb.Any {
|
||||
if x != nil {
|
||||
return x.Data
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type PreConditionFailed struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Operation string `protobuf:"bytes,1,opt,name=operation,proto3" json:"operation,omitempty"`
|
||||
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
|
||||
Input *anypb.Any `protobuf:"bytes,3,opt,name=input,proto3" json:"input,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *PreConditionFailed) Reset() {
|
||||
*x = PreConditionFailed{}
|
||||
mi := &file_messages_proto_msgTypes[16]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *PreConditionFailed) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*PreConditionFailed) ProtoMessage() {}
|
||||
|
||||
func (x *PreConditionFailed) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_messages_proto_msgTypes[16]
|
||||
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 PreConditionFailed.ProtoReflect.Descriptor instead.
|
||||
func (*PreConditionFailed) Descriptor() ([]byte, []int) {
|
||||
return file_messages_proto_rawDescGZIP(), []int{16}
|
||||
}
|
||||
|
||||
func (x *PreConditionFailed) GetOperation() string {
|
||||
if x != nil {
|
||||
return x.Operation
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *PreConditionFailed) GetError() string {
|
||||
if x != nil {
|
||||
return x.Error
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *PreConditionFailed) GetInput() *anypb.Any {
|
||||
if x != nil {
|
||||
return x.Input
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var File_messages_proto protoreflect.FileDescriptor
|
||||
|
||||
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,
|
||||
0x12, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x1a, 0x19, 0x67, 0x6f, 0x6f, 0x67,
|
||||
0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x61, 0x6e, 0x79, 0x2e,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x12, 0x0a, 0x10, 0x43, 0x6c, 0x65, 0x61, 0x72, 0x43, 0x61,
|
||||
0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xb7, 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, 0x1e, 0x0a, 0x0a, 0x73, 0x61, 0x6c, 0x65, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73,
|
||||
0x18, 0x18, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x61, 0x6c, 0x65, 0x53, 0x74, 0x61, 0x74,
|
||||
0x75, 0x73, 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, 0x66, 0x0a, 0x11, 0x49,
|
||||
0x6e, 0x76, 0x65, 0x6e, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x64,
|
||||
0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 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, 0x1d, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73,
|
||||
0x61, 0x67, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x07, 0x6d, 0x65, 0x73,
|
||||
0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x6d, 0x65, 0x73, 0x73,
|
||||
0x61, 0x67, 0x65, 0x22, 0x7c, 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, 0x22, 0x0a, 0x0c, 0x76,
|
||||
0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28,
|
||||
0x09, 0x52, 0x0c, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12,
|
||||
0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f,
|
||||
0x6e, 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, 0x22, 0xa7, 0x01, 0x0a, 0x19, 0x55, 0x70, 0x73, 0x65, 0x72, 0x74, 0x53, 0x75, 0x62,
|
||||
0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73,
|
||||
0x12, 0x13, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x02,
|
||||
0x69, 0x64, 0x88, 0x01, 0x01, 0x12, 0x22, 0x0a, 0x0c, 0x6f, 0x66, 0x66, 0x65, 0x72, 0x69, 0x6e,
|
||||
0x67, 0x43, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6f, 0x66, 0x66,
|
||||
0x65, 0x72, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x73, 0x69, 0x67,
|
||||
0x6e, 0x69, 0x6e, 0x67, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b,
|
||||
0x73, 0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, 0x54, 0x79, 0x70, 0x65, 0x12, 0x28, 0x0a, 0x04, 0x64,
|
||||
0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67,
|
||||
0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52,
|
||||
0x04, 0x64, 0x61, 0x74, 0x61, 0x42, 0x05, 0x0a, 0x03, 0x5f, 0x69, 0x64, 0x22, 0x74, 0x0a, 0x12,
|
||||
0x50, 0x72, 0x65, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x61, 0x69, 0x6c,
|
||||
0x65, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18,
|
||||
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e,
|
||||
0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x2a, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18,
|
||||
0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x69, 0x6e, 0x70,
|
||||
0x75, 0x74, 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 (
|
||||
@@ -1249,7 +1407,7 @@ func file_messages_proto_rawDescGZIP() []byte {
|
||||
return file_messages_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 15)
|
||||
var file_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 17)
|
||||
var file_messages_proto_goTypes = []any{
|
||||
(*ClearCartRequest)(nil), // 0: messages.ClearCartRequest
|
||||
(*AddItem)(nil), // 1: messages.AddItem
|
||||
@@ -1263,18 +1421,22 @@ var file_messages_proto_goTypes = []any{
|
||||
(*OrderCreated)(nil), // 9: messages.OrderCreated
|
||||
(*Noop)(nil), // 10: messages.Noop
|
||||
(*InitializeCheckout)(nil), // 11: messages.InitializeCheckout
|
||||
(*VoucherRule)(nil), // 12: messages.VoucherRule
|
||||
(*InventoryReserved)(nil), // 12: messages.InventoryReserved
|
||||
(*AddVoucher)(nil), // 13: messages.AddVoucher
|
||||
(*RemoveVoucher)(nil), // 14: messages.RemoveVoucher
|
||||
(*UpsertSubscriptionDetails)(nil), // 15: messages.UpsertSubscriptionDetails
|
||||
(*PreConditionFailed)(nil), // 16: messages.PreConditionFailed
|
||||
(*anypb.Any)(nil), // 17: google.protobuf.Any
|
||||
}
|
||||
var file_messages_proto_depIdxs = []int32{
|
||||
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
|
||||
2, // [2:2] is the sub-list for extension extendee
|
||||
0, // [0:2] is the sub-list for field type_name
|
||||
17, // 1: messages.UpsertSubscriptionDetails.data:type_name -> google.protobuf.Any
|
||||
17, // 2: messages.PreConditionFailed.input:type_name -> google.protobuf.Any
|
||||
3, // [3:3] is the sub-list for method output_type
|
||||
3, // [3:3] is the sub-list for method input_type
|
||||
3, // [3:3] is the sub-list for extension type_name
|
||||
3, // [3:3] is the sub-list for extension extendee
|
||||
0, // [0:3] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_messages_proto_init() }
|
||||
@@ -1286,13 +1448,15 @@ func file_messages_proto_init() {
|
||||
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[12].OneofWrappers = []any{}
|
||||
file_messages_proto_msgTypes[15].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: 15,
|
||||
NumMessages: 17,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
|
||||
724
pkg/promotions/eval.go
Normal file
724
pkg/promotions/eval.go
Normal file
@@ -0,0 +1,724 @@
|
||||
package promotions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||
)
|
||||
|
||||
var errInvalidTimeFormat = errors.New("invalid time format")
|
||||
|
||||
// Tracer allows callers to receive structured debug information during evaluation.
|
||||
type Tracer interface {
|
||||
Trace(event string, data map[string]any)
|
||||
}
|
||||
|
||||
// NoopTracer is used when no tracer provided.
|
||||
type NoopTracer struct{}
|
||||
|
||||
func (NoopTracer) Trace(string, map[string]any) {}
|
||||
|
||||
// PromotionItem is a lightweight abstraction derived from cart.CartItem
|
||||
// for the purpose of promotion condition evaluation.
|
||||
type PromotionItem struct {
|
||||
SKU string
|
||||
Quantity int
|
||||
Category string
|
||||
PriceIncVat int64
|
||||
}
|
||||
|
||||
// PromotionEvalContext carries all dynamic data required to evaluate promotion
|
||||
// conditions. It can be constructed from a cart.CartGrain plus optional
|
||||
// customer/order metadata.
|
||||
type PromotionEvalContext struct {
|
||||
CartTotalIncVat int64
|
||||
TotalItemQuantity int
|
||||
Items []PromotionItem
|
||||
CustomerSegment string
|
||||
CustomerLifetimeValue float64
|
||||
OrderCount int
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
// ContextOption allows customization of fields when building a PromotionEvalContext.
|
||||
type ContextOption func(*PromotionEvalContext)
|
||||
|
||||
// WithCustomerSegment sets the customer segment.
|
||||
func WithCustomerSegment(seg string) ContextOption {
|
||||
return func(c *PromotionEvalContext) { c.CustomerSegment = seg }
|
||||
}
|
||||
|
||||
// WithCustomerLifetimeValue sets lifetime value metric.
|
||||
func WithCustomerLifetimeValue(v float64) ContextOption {
|
||||
return func(c *PromotionEvalContext) { c.CustomerLifetimeValue = v }
|
||||
}
|
||||
|
||||
// WithOrderCount sets historical order count.
|
||||
func WithOrderCount(n int) ContextOption {
|
||||
return func(c *PromotionEvalContext) { c.OrderCount = n }
|
||||
}
|
||||
|
||||
// WithNow overrides the timestamp used for date/time related conditions.
|
||||
func WithNow(t time.Time) ContextOption {
|
||||
return func(c *PromotionEvalContext) { c.Now = t }
|
||||
}
|
||||
|
||||
// NewContextFromCart builds a PromotionEvalContext from a CartGrain and optional metadata.
|
||||
func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEvalContext {
|
||||
ctx := &PromotionEvalContext{
|
||||
Items: make([]PromotionItem, 0, len(g.Items)),
|
||||
CartTotalIncVat: 0,
|
||||
TotalItemQuantity: 0,
|
||||
Now: time.Now(),
|
||||
}
|
||||
if g.TotalPrice != nil {
|
||||
ctx.CartTotalIncVat = g.TotalPrice.IncVat
|
||||
}
|
||||
for _, it := range g.Items {
|
||||
category := ""
|
||||
if it.Meta != nil {
|
||||
category = it.Meta.Category
|
||||
}
|
||||
ctx.Items = append(ctx.Items, PromotionItem{
|
||||
SKU: it.Sku,
|
||||
Quantity: it.Quantity,
|
||||
Category: strings.ToLower(category),
|
||||
PriceIncVat: it.Price.IncVat,
|
||||
})
|
||||
ctx.TotalItemQuantity += it.Quantity
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(ctx)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
// PromotionService evaluates PromotionRules against a PromotionEvalContext.
|
||||
type PromotionService struct {
|
||||
tracer Tracer
|
||||
}
|
||||
|
||||
// NewPromotionService constructs a PromotionService with an optional tracer.
|
||||
func NewPromotionService(t Tracer) *PromotionService {
|
||||
if t == nil {
|
||||
t = NoopTracer{}
|
||||
}
|
||||
return &PromotionService{tracer: t}
|
||||
}
|
||||
|
||||
// EvaluationResult holds the outcome of evaluating a single rule.
|
||||
type EvaluationResult struct {
|
||||
Rule PromotionRule
|
||||
Applicable bool
|
||||
FailedReason string
|
||||
MatchedActions []Action
|
||||
}
|
||||
|
||||
// EvaluateRule determines if a single PromotionRule applies to the provided context.
|
||||
// Returns an EvaluationResult with applicability and actions (if applicable).
|
||||
func (s *PromotionService) EvaluateRule(rule PromotionRule, ctx *PromotionEvalContext) EvaluationResult {
|
||||
s.tracer.Trace("rule_start", map[string]any{
|
||||
"rule_id": rule.ID,
|
||||
"priority": rule.Priority,
|
||||
"status": rule.Status,
|
||||
"startDate": rule.StartDate,
|
||||
"endDate": rule.EndDate,
|
||||
})
|
||||
// Status gate
|
||||
now := ctx.Now
|
||||
switch rule.Status {
|
||||
case StatusInactive, StatusExpired:
|
||||
s.tracer.Trace("rule_skip_status", map[string]any{"rule_id": rule.ID, "status": rule.Status})
|
||||
return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "status"}
|
||||
case StatusScheduled:
|
||||
// Allow scheduled only if current time >= startDate (and within endDate if present)
|
||||
}
|
||||
// Date window checks (if parseable)
|
||||
if rule.StartDate != "" {
|
||||
if tStart, err := parseDate(rule.StartDate); err == nil {
|
||||
if now.Before(tStart) {
|
||||
s.tracer.Trace("rule_skip_before_start", map[string]any{"rule_id": rule.ID})
|
||||
return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "before_start"}
|
||||
}
|
||||
}
|
||||
}
|
||||
if rule.EndDate != nil && *rule.EndDate != "" {
|
||||
if tEnd, err := parseDate(*rule.EndDate); err == nil {
|
||||
if now.After(tEnd.Add(23*time.Hour + 59*time.Minute + 59*time.Second)) { // inclusive day
|
||||
s.tracer.Trace("rule_skip_after_end", map[string]any{"rule_id": rule.ID})
|
||||
return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "after_end"}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Usage limit
|
||||
if rule.UsageLimit != nil && rule.UsageCount >= *rule.UsageLimit {
|
||||
s.tracer.Trace("rule_skip_usage_limit", map[string]any{"rule_id": rule.ID, "usageCount": rule.UsageCount, "limit": *rule.UsageLimit})
|
||||
return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "usage_limit_exhausted"}
|
||||
}
|
||||
|
||||
if !evaluateConditionsTrace(rule.Conditions, ctx, s.tracer, rule.ID) {
|
||||
s.tracer.Trace("rule_conditions_failed", map[string]any{"rule_id": rule.ID})
|
||||
return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "conditions"}
|
||||
}
|
||||
|
||||
s.tracer.Trace("rule_applicable", map[string]any{"rule_id": rule.ID})
|
||||
return EvaluationResult{
|
||||
Rule: rule,
|
||||
Applicable: true,
|
||||
MatchedActions: rule.Actions,
|
||||
}
|
||||
}
|
||||
|
||||
// EvaluateAll returns all applicable promotion actions given a list of rules and context.
|
||||
// Rules marked Applicable are sorted by Priority (ascending: lower number = higher precedence).
|
||||
func (s *PromotionService) EvaluateAll(rules []PromotionRule, ctx *PromotionEvalContext) ([]EvaluationResult, []Action) {
|
||||
results := make([]EvaluationResult, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
res := s.EvaluateRule(r, ctx)
|
||||
results = append(results, res)
|
||||
}
|
||||
actions := make([]Action, 0)
|
||||
for _, res := range orderedByPriority(results) {
|
||||
if res.Applicable {
|
||||
s.tracer.Trace("actions_add", map[string]any{
|
||||
"rule_id": res.Rule.ID,
|
||||
"count": len(res.MatchedActions),
|
||||
"priority": res.Rule.Priority,
|
||||
})
|
||||
actions = append(actions, res.MatchedActions...)
|
||||
}
|
||||
}
|
||||
s.tracer.Trace("evaluation_complete", map[string]any{
|
||||
"rules_total": len(rules),
|
||||
"actions": len(actions),
|
||||
})
|
||||
return results, actions
|
||||
}
|
||||
|
||||
// orderedByPriority returns results sorted by PromotionRule.Priority ascending (stable).
|
||||
func orderedByPriority(in []EvaluationResult) []EvaluationResult {
|
||||
out := make([]EvaluationResult, len(in))
|
||||
copy(out, in)
|
||||
for i := 1; i < len(out); i++ {
|
||||
j := i
|
||||
for j > 0 && out[j-1].Rule.Priority > out[j].Rule.Priority {
|
||||
out[j-1], out[j] = out[j], out[j-1]
|
||||
j--
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// Condition evaluation (with tracing)
|
||||
// ----------------------------
|
||||
|
||||
func evaluateConditionsTrace(conds Conditions, ctx *PromotionEvalContext, t Tracer, ruleID string) bool {
|
||||
for idx, c := range conds {
|
||||
if !evaluateConditionTrace(c, ctx, t, ruleID, fmt.Sprintf("root[%d]", idx)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func evaluateConditionTrace(c Condition, ctx *PromotionEvalContext, t Tracer, ruleID, path string) bool {
|
||||
if grp, ok := c.(ConditionGroup); ok {
|
||||
return evaluateGroupTrace(grp, ctx, t, ruleID, path)
|
||||
}
|
||||
bc, ok := c.(BaseCondition)
|
||||
if !ok {
|
||||
t.Trace("cond_invalid_type", map[string]any{"rule_id": ruleID, "path": path})
|
||||
return false
|
||||
}
|
||||
res := evaluateBaseCondition(bc, ctx)
|
||||
t.Trace("cond_base", map[string]any{
|
||||
"rule_id": ruleID,
|
||||
"path": path,
|
||||
"type": bc.Type,
|
||||
"op": bc.Operator,
|
||||
"value": bc.Value.String(),
|
||||
"result": res,
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
func evaluateGroupTrace(g ConditionGroup, ctx *PromotionEvalContext, t Tracer, ruleID, path string) bool {
|
||||
op := normalizeLogicOperator(string(g.Operator))
|
||||
if len(g.Conditions) == 0 {
|
||||
t.Trace("cond_group_empty", map[string]any{"rule_id": ruleID, "path": path})
|
||||
return true
|
||||
}
|
||||
if op == string(LogicAND) {
|
||||
for i, child := range g.Conditions {
|
||||
if !evaluateConditionTrace(child, ctx, t, ruleID, path+fmt.Sprintf(".AND[%d]", i)) {
|
||||
t.Trace("cond_group_and_fail", map[string]any{"rule_id": ruleID, "path": path})
|
||||
return false
|
||||
}
|
||||
}
|
||||
t.Trace("cond_group_and_pass", map[string]any{"rule_id": ruleID, "path": path})
|
||||
return true
|
||||
}
|
||||
for i, child := range g.Conditions {
|
||||
if evaluateConditionTrace(child, ctx, t, ruleID, path+fmt.Sprintf(".OR[%d]", i)) {
|
||||
t.Trace("cond_group_or_pass", map[string]any{"rule_id": ruleID, "path": path})
|
||||
return true
|
||||
}
|
||||
}
|
||||
t.Trace("cond_group_or_fail", map[string]any{"rule_id": ruleID, "path": path})
|
||||
return false
|
||||
}
|
||||
|
||||
// Fallback non-traced evaluation (used internally by traced path)
|
||||
func evaluateConditions(conds Conditions, ctx *PromotionEvalContext) bool {
|
||||
for _, c := range conds {
|
||||
if !evaluateCondition(c, ctx) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func evaluateCondition(c Condition, ctx *PromotionEvalContext) bool {
|
||||
if grp, ok := c.(ConditionGroup); ok {
|
||||
return evaluateGroup(grp, ctx)
|
||||
}
|
||||
bc, ok := c.(BaseCondition)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return evaluateBaseCondition(bc, ctx)
|
||||
}
|
||||
|
||||
func evaluateGroup(g ConditionGroup, ctx *PromotionEvalContext) bool {
|
||||
op := normalizeLogicOperator(string(g.Operator))
|
||||
if len(g.Conditions) == 0 {
|
||||
return true
|
||||
}
|
||||
if op == string(LogicAND) {
|
||||
for _, child := range g.Conditions {
|
||||
if !evaluateCondition(child, ctx) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
for _, child := range g.Conditions {
|
||||
if evaluateCondition(child, ctx) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func evaluateBaseCondition(b BaseCondition, ctx *PromotionEvalContext) bool {
|
||||
switch b.Type {
|
||||
case CondCartTotal:
|
||||
return evalNumberCompare(float64(ctx.CartTotalIncVat), b)
|
||||
case CondItemQuantity:
|
||||
return evalNumberCompare(float64(ctx.TotalItemQuantity), b)
|
||||
case CondCustomerSegment:
|
||||
return evalStringCompare(ctx.CustomerSegment, b)
|
||||
case CondProductCategory:
|
||||
return evalAnyItemMatch(func(it PromotionItem) bool {
|
||||
return evalValueAgainstTarget(strings.ToLower(it.Category), b)
|
||||
}, b, ctx)
|
||||
case CondProductID:
|
||||
return evalAnyItemMatch(func(it PromotionItem) bool {
|
||||
return evalValueAgainstTarget(strings.ToLower(it.SKU), b)
|
||||
}, b, ctx)
|
||||
case CondCustomerLifetime:
|
||||
return evalNumberCompare(ctx.CustomerLifetimeValue, b)
|
||||
case CondOrderCount:
|
||||
return evalNumberCompare(float64(ctx.OrderCount), b)
|
||||
case CondDateRange:
|
||||
return evalDateRange(ctx.Now, b)
|
||||
case CondDayOfWeek:
|
||||
return evalDayOfWeek(ctx.Now, b)
|
||||
case CondTimeOfDay:
|
||||
return evalTimeOfDay(ctx.Now, b)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func evalAnyItemMatch(pred func(PromotionItem) bool, b BaseCondition, ctx *PromotionEvalContext) bool {
|
||||
if slices.ContainsFunc(ctx.Items, pred) {
|
||||
return true
|
||||
}
|
||||
switch normalizeOperator(string(b.Operator)) {
|
||||
case string(OpNotIn), string(OpNotContains):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// Primitive evaluators
|
||||
// ----------------------------
|
||||
|
||||
func evalNumberCompare(target float64, b BaseCondition) bool {
|
||||
op := normalizeOperator(string(b.Operator))
|
||||
cond, ok := b.Value.AsFloat64()
|
||||
if !ok {
|
||||
if list, okL := b.Value.AsFloat64Slice(); okL && (op == string(OpIn) || op == string(OpNotIn)) {
|
||||
found := sliceFloatContains(list, target)
|
||||
if op == string(OpIn) {
|
||||
return found
|
||||
}
|
||||
return !found
|
||||
}
|
||||
return false
|
||||
}
|
||||
switch op {
|
||||
case string(OpEquals):
|
||||
return target == cond
|
||||
case string(OpNotEquals):
|
||||
return target != cond
|
||||
case string(OpGreaterThan):
|
||||
return target > cond
|
||||
case string(OpLessThan):
|
||||
return target < cond
|
||||
case string(OpGreaterOrEqual):
|
||||
return target >= cond
|
||||
case string(OpLessOrEqual):
|
||||
return target <= cond
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func evalStringCompare(target string, b BaseCondition) bool {
|
||||
op := normalizeOperator(string(b.Operator))
|
||||
targetLower := strings.ToLower(target)
|
||||
|
||||
if s, ok := b.Value.AsString(); ok {
|
||||
condLower := strings.ToLower(s)
|
||||
switch op {
|
||||
case string(OpEquals):
|
||||
return targetLower == condLower
|
||||
case string(OpNotEquals):
|
||||
return targetLower != condLower
|
||||
case string(OpContains):
|
||||
return strings.Contains(targetLower, condLower)
|
||||
case string(OpNotContains):
|
||||
return !strings.Contains(targetLower, condLower)
|
||||
}
|
||||
}
|
||||
|
||||
if arr, ok := b.Value.AsStringSlice(); ok {
|
||||
switch op {
|
||||
case string(OpIn):
|
||||
for _, v := range arr {
|
||||
if targetLower == strings.ToLower(v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case string(OpNotIn):
|
||||
for _, v := range arr {
|
||||
if targetLower == strings.ToLower(v) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case string(OpContains):
|
||||
for _, v := range arr {
|
||||
if strings.Contains(targetLower, strings.ToLower(v)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case string(OpNotContains):
|
||||
for _, v := range arr {
|
||||
if strings.Contains(targetLower, strings.ToLower(v)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func evalValueAgainstTarget(target string, b BaseCondition) bool {
|
||||
op := normalizeOperator(string(b.Operator))
|
||||
tLower := strings.ToLower(target)
|
||||
|
||||
if s, ok := b.Value.AsString(); ok {
|
||||
vLower := strings.ToLower(s)
|
||||
switch op {
|
||||
case string(OpEquals):
|
||||
return tLower == vLower
|
||||
case string(OpNotEquals):
|
||||
return tLower != vLower
|
||||
case string(OpContains):
|
||||
return strings.Contains(tLower, vLower)
|
||||
case string(OpNotContains):
|
||||
return !strings.Contains(tLower, vLower)
|
||||
case string(OpIn):
|
||||
return tLower == vLower
|
||||
case string(OpNotIn):
|
||||
return tLower != vLower
|
||||
}
|
||||
}
|
||||
|
||||
if list, ok := b.Value.AsStringSlice(); ok {
|
||||
found := false
|
||||
for _, v := range list {
|
||||
if tLower == strings.ToLower(v) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
switch op {
|
||||
case string(OpIn):
|
||||
return found
|
||||
case string(OpNotIn):
|
||||
return !found
|
||||
case string(OpContains):
|
||||
for _, v := range list {
|
||||
if strings.Contains(tLower, strings.ToLower(v)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case string(OpNotContains):
|
||||
for _, v := range list {
|
||||
if strings.Contains(tLower, strings.ToLower(v)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func evalDateRange(now time.Time, b BaseCondition) bool {
|
||||
var start, end time.Time
|
||||
if ss, ok := b.Value.AsStringSlice(); ok && len(ss) == 2 {
|
||||
t0, e0 := parseDate(ss[0])
|
||||
t1, e1 := parseDate(ss[1])
|
||||
if e0 != nil || e1 != nil {
|
||||
return false
|
||||
}
|
||||
start, end = t0, t1
|
||||
} else if s, ok := b.Value.AsString(); ok {
|
||||
parts := strings.Split(s, "..")
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
t0, e0 := parseDate(parts[0])
|
||||
t1, e1 := parseDate(parts[1])
|
||||
if e0 != nil || e1 != nil {
|
||||
return false
|
||||
}
|
||||
start, end = t0, t1
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
endInclusive := end.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
|
||||
return (now.Equal(start) || now.After(start)) && (now.Equal(endInclusive) || now.Before(endInclusive))
|
||||
}
|
||||
|
||||
func evalDayOfWeek(now time.Time, b BaseCondition) bool {
|
||||
dow := int(now.Weekday())
|
||||
allowed := make(map[int]struct{})
|
||||
if arr, ok := b.Value.AsStringSlice(); ok {
|
||||
for _, v := range arr {
|
||||
if idx, ok := parseDayOfWeek(v); ok {
|
||||
allowed[idx] = struct{}{}
|
||||
}
|
||||
}
|
||||
} else if s, ok := b.Value.AsString(); ok {
|
||||
for _, part := range strings.Split(s, "|") {
|
||||
if idx, ok := parseDayOfWeek(strings.TrimSpace(part)); ok {
|
||||
allowed[idx] = struct{}{}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(allowed) == 0 {
|
||||
return false
|
||||
}
|
||||
op := normalizeOperator(string(b.Operator))
|
||||
_, present := allowed[dow]
|
||||
if op == string(OpIn) || op == string(OpEquals) || op == "" {
|
||||
return present
|
||||
}
|
||||
if op == string(OpNotIn) || op == string(OpNotEquals) {
|
||||
return !present
|
||||
}
|
||||
return present
|
||||
}
|
||||
|
||||
func evalTimeOfDay(now time.Time, b BaseCondition) bool {
|
||||
var startMin, endMin int
|
||||
if arr, ok := b.Value.AsStringSlice(); ok && len(arr) == 2 {
|
||||
s0, e0 := parseClock(arr[0])
|
||||
s1, e1 := parseClock(arr[1])
|
||||
if e0 != nil || e1 != nil {
|
||||
return false
|
||||
}
|
||||
startMin, endMin = s0, s1
|
||||
} else if s, ok := b.Value.AsString(); ok {
|
||||
parts := strings.Split(s, "-")
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
s0, e0 := parseClock(parts[0])
|
||||
s1, e1 := parseClock(parts[1])
|
||||
if e0 != nil || e1 != nil {
|
||||
return false
|
||||
}
|
||||
startMin, endMin = s0, s1
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
todayMin := now.Hour()*60 + now.Minute()
|
||||
if startMin > endMin {
|
||||
return todayMin >= startMin || todayMin <= endMin
|
||||
}
|
||||
return todayMin >= startMin && todayMin <= endMin
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// Parsing helpers
|
||||
// ----------------------------
|
||||
|
||||
func parseDate(s string) (time.Time, error) {
|
||||
layouts := []string{
|
||||
"2006-01-02",
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05Z07:00",
|
||||
}
|
||||
for _, l := range layouts {
|
||||
if t, err := time.Parse(l, strings.TrimSpace(s)); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, errInvalidTimeFormat
|
||||
}
|
||||
|
||||
func parseDayOfWeek(s string) (int, bool) {
|
||||
sl := strings.ToLower(strings.TrimSpace(s))
|
||||
switch sl {
|
||||
case "sun", "sunday", "0":
|
||||
return 0, true
|
||||
case "mon", "monday", "1":
|
||||
return 1, true
|
||||
case "tue", "tues", "tuesday", "2":
|
||||
return 2, true
|
||||
case "wed", "weds", "wednesday", "3":
|
||||
return 3, true
|
||||
case "thu", "thur", "thurs", "thursday", "4":
|
||||
return 4, true
|
||||
case "fri", "friday", "5":
|
||||
return 5, true
|
||||
case "sat", "saturday", "6":
|
||||
return 6, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func parseClock(s string) (int, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
parts := strings.Split(s, ":")
|
||||
if len(parts) != 2 {
|
||||
return 0, errInvalidTimeFormat
|
||||
}
|
||||
h, err := parsePositiveInt(parts[0])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
m, err := parsePositiveInt(parts[1])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if h < 0 || h > 23 || m < 0 || m > 59 {
|
||||
return 0, errInvalidTimeFormat
|
||||
}
|
||||
return h*60 + m, nil
|
||||
}
|
||||
|
||||
func parsePositiveInt(s string) (int, error) {
|
||||
n := 0
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return 0, errInvalidTimeFormat
|
||||
}
|
||||
n = n*10 + int(r-'0')
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func normalizeOperator(op string) string {
|
||||
o := strings.ToLower(strings.TrimSpace(op))
|
||||
switch o {
|
||||
case "=", "equals", "eq":
|
||||
return string(OpEquals)
|
||||
case "!=", "not_equals", "neq":
|
||||
return string(OpNotEquals)
|
||||
case ">", "greater_than", "gt":
|
||||
return string(OpGreaterThan)
|
||||
case "<", "less_than", "lt":
|
||||
return string(OpLessThan)
|
||||
case ">=", "greater_or_equal", "ge", "gte":
|
||||
return string(OpGreaterOrEqual)
|
||||
case "<=", "less_or_equal", "le", "lte":
|
||||
return string(OpLessOrEqual)
|
||||
case "contains":
|
||||
return string(OpContains)
|
||||
case "not_contains":
|
||||
return string(OpNotContains)
|
||||
case "in":
|
||||
return string(OpIn)
|
||||
case "not_in":
|
||||
return string(OpNotIn)
|
||||
default:
|
||||
return o
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLogicOperator(op string) string {
|
||||
o := strings.ToLower(strings.TrimSpace(op))
|
||||
switch o {
|
||||
case "&&", "and":
|
||||
return string(LogicAND)
|
||||
case "||", "or":
|
||||
return string(LogicOR)
|
||||
default:
|
||||
return o
|
||||
}
|
||||
}
|
||||
|
||||
func sliceFloatContains(list []float64, v float64) bool {
|
||||
return slices.Contains(list, v)
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// Potential extension hooks
|
||||
// ----------------------------
|
||||
//
|
||||
// Future ideas:
|
||||
// - Conflict resolution strategies (e.g., best discount wins, stackable tags)
|
||||
// - Action transformation (e.g., applying tiered logic or bundles carefully)
|
||||
// - Recording evaluation traces for debugging / analytics.
|
||||
// - Tracing instrumentation for condition evaluation.
|
||||
//
|
||||
// These can be integrated by adding strategy interfaces or injecting evaluators
|
||||
// into PromotionService.
|
||||
//
|
||||
// ----------------------------
|
||||
// End of file
|
||||
// ----------------------------
|
||||
448
pkg/promotions/eval_test.go
Normal file
448
pkg/promotions/eval_test.go
Normal file
@@ -0,0 +1,448 @@
|
||||
package promotions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||
)
|
||||
|
||||
// --- Helpers ---------------------------------------------------------------
|
||||
|
||||
func cvNum(n float64) ConditionValue {
|
||||
b, _ := json.Marshal(n)
|
||||
return ConditionValue{Raw: b}
|
||||
}
|
||||
|
||||
func cvString(s string) ConditionValue {
|
||||
b, _ := json.Marshal(s)
|
||||
return ConditionValue{Raw: b}
|
||||
}
|
||||
|
||||
func cvStrings(ss []string) ConditionValue {
|
||||
b, _ := json.Marshal(ss)
|
||||
return ConditionValue{Raw: b}
|
||||
}
|
||||
|
||||
// testTracer captures trace events for assertions.
|
||||
type testTracer struct {
|
||||
events []traceEvent
|
||||
}
|
||||
|
||||
type traceEvent struct {
|
||||
event string
|
||||
data map[string]any
|
||||
}
|
||||
|
||||
func (t *testTracer) Trace(event string, data map[string]any) {
|
||||
t.events = append(t.events, traceEvent{event: event, data: data})
|
||||
}
|
||||
|
||||
func (t *testTracer) HasEvent(name string) bool {
|
||||
return slices.ContainsFunc(t.events, func(e traceEvent) bool { return e.event == name })
|
||||
}
|
||||
|
||||
func (t *testTracer) Count(name string) int {
|
||||
c := 0
|
||||
for _, e := range t.events {
|
||||
if e.event == name {
|
||||
c++
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// makeCart creates a cart with given total and items (each item quantity & price IncVat).
|
||||
func makeCart(totalOverride int64, items []struct {
|
||||
sku string
|
||||
category string
|
||||
qty int
|
||||
priceInc int64
|
||||
}) *cart.CartGrain {
|
||||
g := cart.NewCartGrain(1, time.Now())
|
||||
for _, it := range items {
|
||||
p := cart.NewPriceFromIncVat(it.priceInc, 0.25)
|
||||
g.Items = append(g.Items, &cart.CartItem{
|
||||
Id: uint32(len(g.Items) + 1),
|
||||
Sku: it.sku,
|
||||
Price: *p,
|
||||
TotalPrice: cart.Price{
|
||||
IncVat: p.IncVat * int64(it.qty),
|
||||
VatRates: p.VatRates,
|
||||
},
|
||||
Quantity: it.qty,
|
||||
Meta: &cart.ItemMeta{
|
||||
Category: it.category,
|
||||
},
|
||||
})
|
||||
}
|
||||
// Recalculate totals
|
||||
g.UpdateTotals()
|
||||
if totalOverride >= 0 {
|
||||
g.TotalPrice.IncVat = totalOverride
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// --- Tests -----------------------------------------------------------------
|
||||
|
||||
func TestEvaluateRuleBasicAND(t *testing.T) {
|
||||
g := makeCart(12000, []struct {
|
||||
sku string
|
||||
category string
|
||||
qty int
|
||||
priceInc int64
|
||||
}{
|
||||
{"SKU-1", "summer", 2, 3000},
|
||||
{"SKU-2", "winter", 1, 6000},
|
||||
})
|
||||
|
||||
ctx := NewContextFromCart(g,
|
||||
WithCustomerSegment("vip"),
|
||||
WithOrderCount(10),
|
||||
WithCustomerLifetimeValue(1234.56),
|
||||
WithNow(time.Date(2024, 6, 10, 12, 0, 0, 0, time.UTC)),
|
||||
)
|
||||
|
||||
// Conditions: cart_total >= 10000 AND item_quantity >= 3 AND customer_segment = vip
|
||||
conds := Conditions{
|
||||
ConditionGroup{
|
||||
ID: "grp",
|
||||
Type: "group",
|
||||
Operator: LogicAND,
|
||||
Conditions: Conditions{
|
||||
BaseCondition{
|
||||
ID: "c_cart_total",
|
||||
Type: CondCartTotal,
|
||||
Operator: OpGreaterOrEqual,
|
||||
Value: cvNum(10000),
|
||||
},
|
||||
BaseCondition{
|
||||
ID: "c_item_qty",
|
||||
Type: CondItemQuantity,
|
||||
Operator: OpGreaterOrEqual,
|
||||
Value: cvNum(3),
|
||||
},
|
||||
BaseCondition{
|
||||
ID: "c_segment",
|
||||
Type: CondCustomerSegment,
|
||||
Operator: OpEquals,
|
||||
Value: cvString("vip"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
rule := PromotionRule{
|
||||
ID: "rule-AND",
|
||||
Name: "VIP Summer",
|
||||
Description: "Test rule",
|
||||
Status: StatusActive,
|
||||
Priority: 1,
|
||||
StartDate: "2024-06-01",
|
||||
EndDate: ptr("2024-06-30"),
|
||||
Conditions: conds,
|
||||
Actions: []Action{
|
||||
{ID: "a1", Type: ActionPercentageDiscount, Value: 10.0},
|
||||
},
|
||||
UsageLimit: ptrInt(100),
|
||||
UsageCount: 5,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||
CreatedBy: "tester",
|
||||
}
|
||||
|
||||
tracer := &testTracer{}
|
||||
svc := NewPromotionService(tracer)
|
||||
res := svc.EvaluateRule(rule, ctx)
|
||||
if !res.Applicable {
|
||||
t.Fatalf("expected rule to be applicable, failedReason=%s", res.FailedReason)
|
||||
}
|
||||
if len(res.MatchedActions) != 1 {
|
||||
t.Fatalf("expected 1 action, got %d", len(res.MatchedActions))
|
||||
}
|
||||
if !tracer.HasEvent("rule_applicable") {
|
||||
t.Errorf("expected tracing event rule_applicable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateRuleUsageLimitExhausted(t *testing.T) {
|
||||
rule := PromotionRule{
|
||||
ID: "limit",
|
||||
Name: "Limit",
|
||||
Status: StatusActive,
|
||||
Priority: 1,
|
||||
StartDate: "2024-01-01",
|
||||
EndDate: nil,
|
||||
Conditions: Conditions{},
|
||||
UsageLimit: ptrInt(5),
|
||||
UsageCount: 5,
|
||||
}
|
||||
|
||||
ctx := &PromotionEvalContext{Now: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)}
|
||||
tracer := &testTracer{}
|
||||
svc := NewPromotionService(tracer)
|
||||
res := svc.EvaluateRule(rule, ctx)
|
||||
if res.Applicable {
|
||||
t.Fatalf("expected rule NOT applicable due to usage limit")
|
||||
}
|
||||
if res.FailedReason != "usage_limit_exhausted" {
|
||||
t.Fatalf("expected failedReason usage_limit_exhausted, got %s", res.FailedReason)
|
||||
}
|
||||
if !tracer.HasEvent("rule_skip_usage_limit") {
|
||||
t.Errorf("tracer missing rule_skip_usage_limit event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateRuleDateWindow(t *testing.T) {
|
||||
// Start in future
|
||||
rule := PromotionRule{
|
||||
ID: "date",
|
||||
Name: "DateWindow",
|
||||
Status: StatusActive,
|
||||
Priority: 1,
|
||||
StartDate: "2025-01-01",
|
||||
EndDate: ptr("2025-01-31"),
|
||||
Conditions: Conditions{},
|
||||
}
|
||||
ctx := &PromotionEvalContext{Now: time.Date(2024, 12, 15, 12, 0, 0, 0, time.UTC)}
|
||||
tracer := &testTracer{}
|
||||
svc := NewPromotionService(tracer)
|
||||
res := svc.EvaluateRule(rule, ctx)
|
||||
if res.Applicable {
|
||||
t.Fatalf("expected rule NOT applicable (before start)")
|
||||
}
|
||||
if res.FailedReason != "before_start" {
|
||||
t.Fatalf("expected failedReason before_start, got %s", res.FailedReason)
|
||||
}
|
||||
if !tracer.HasEvent("rule_skip_before_start") {
|
||||
t.Errorf("missing rule_skip_before_start trace event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateProductCategoryIN(t *testing.T) {
|
||||
g := makeCart(-1, []struct {
|
||||
sku string
|
||||
category string
|
||||
qty int
|
||||
priceInc int64
|
||||
}{
|
||||
{"A", "shoes", 1, 5000},
|
||||
{"B", "bags", 1, 7000},
|
||||
})
|
||||
|
||||
ctx := NewContextFromCart(g)
|
||||
conds := Conditions{
|
||||
BaseCondition{
|
||||
ID: "c_category",
|
||||
Type: CondProductCategory,
|
||||
Operator: OpIn,
|
||||
Value: cvStrings([]string{"shoes", "hats"}),
|
||||
},
|
||||
}
|
||||
rule := PromotionRule{
|
||||
ID: "cat-in",
|
||||
Status: StatusActive,
|
||||
Priority: 10,
|
||||
StartDate: "2024-01-01",
|
||||
EndDate: nil,
|
||||
Conditions: conds,
|
||||
Actions: []Action{
|
||||
{ID: "discount", Type: ActionFixedDiscount, Value: 1000},
|
||||
},
|
||||
}
|
||||
tracer := &testTracer{}
|
||||
svc := NewPromotionService(tracer)
|
||||
res := svc.EvaluateRule(rule, ctx)
|
||||
if !res.Applicable {
|
||||
t.Fatalf("expected category IN rule to apply")
|
||||
}
|
||||
if !tracer.HasEvent("rule_applicable") {
|
||||
t.Errorf("tracing missing rule_applicable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateGroupOR(t *testing.T) {
|
||||
g := makeCart(3000, []struct {
|
||||
sku string
|
||||
category string
|
||||
qty int
|
||||
priceInc int64
|
||||
}{
|
||||
{"ONE", "x", 1, 3000},
|
||||
})
|
||||
|
||||
ctx := NewContextFromCart(g)
|
||||
|
||||
// OR group: (cart_total >= 10000) OR (item_quantity >= 1)
|
||||
group := ConditionGroup{
|
||||
ID: "grp-or",
|
||||
Type: "group",
|
||||
Operator: LogicOR,
|
||||
Conditions: Conditions{
|
||||
BaseCondition{
|
||||
ID: "c_total",
|
||||
Type: CondCartTotal,
|
||||
Operator: OpGreaterOrEqual,
|
||||
Value: cvNum(10000),
|
||||
},
|
||||
BaseCondition{
|
||||
ID: "c_qty",
|
||||
Type: CondItemQuantity,
|
||||
Operator: OpGreaterOrEqual,
|
||||
Value: cvNum(1),
|
||||
},
|
||||
},
|
||||
}
|
||||
rule := PromotionRule{
|
||||
ID: "or-rule",
|
||||
Status: StatusActive,
|
||||
Priority: 5,
|
||||
StartDate: "2024-01-01",
|
||||
EndDate: nil,
|
||||
Conditions: Conditions{group},
|
||||
Actions: []Action{{ID: "a", Type: ActionFixedDiscount, Value: 500}},
|
||||
}
|
||||
tracer := &testTracer{}
|
||||
svc := NewPromotionService(tracer)
|
||||
res := svc.EvaluateRule(rule, ctx)
|
||||
if !res.Applicable {
|
||||
t.Fatalf("expected OR rule to apply (second condition true)")
|
||||
}
|
||||
// Ensure group pass event
|
||||
if !tracer.HasEvent("cond_group_or_pass") {
|
||||
t.Errorf("expected cond_group_or_pass trace event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateAllPriorityOrdering(t *testing.T) {
|
||||
ctx := &PromotionEvalContext{
|
||||
CartTotalIncVat: 20000,
|
||||
TotalItemQuantity: 2,
|
||||
Items: []PromotionItem{
|
||||
{SKU: "X", Quantity: 2, Category: "general", PriceIncVat: 10000},
|
||||
},
|
||||
Now: time.Date(2024, 5, 10, 10, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
// Rule A: priority 5
|
||||
ruleA := PromotionRule{
|
||||
ID: "A",
|
||||
Status: StatusActive,
|
||||
Priority: 5,
|
||||
StartDate: "2024-01-01",
|
||||
EndDate: nil,
|
||||
Conditions: Conditions{},
|
||||
Actions: []Action{
|
||||
{ID: "actionA", Type: ActionFixedDiscount, Value: 100},
|
||||
},
|
||||
}
|
||||
|
||||
// Rule B: priority 1
|
||||
ruleB := PromotionRule{
|
||||
ID: "B",
|
||||
Status: StatusActive,
|
||||
Priority: 1,
|
||||
StartDate: "2024-01-01",
|
||||
EndDate: nil,
|
||||
Conditions: Conditions{},
|
||||
Actions: []Action{
|
||||
{ID: "actionB", Type: ActionFixedDiscount, Value: 200},
|
||||
},
|
||||
}
|
||||
|
||||
tracer := &testTracer{}
|
||||
svc := NewPromotionService(tracer)
|
||||
results, actions := svc.EvaluateAll([]PromotionRule{ruleA, ruleB}, ctx)
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2 results, got %d", len(results))
|
||||
}
|
||||
if len(actions) != 2 {
|
||||
t.Fatalf("expected 2 actions, got %d", len(actions))
|
||||
}
|
||||
|
||||
// Actions should follow priority order: ruleB (1) then ruleA (5)
|
||||
if actions[0].ID != "actionB" || actions[1].ID != "actionA" {
|
||||
t.Fatalf("actions order invalid: %+v", actions)
|
||||
}
|
||||
if tracer.Count("actions_add") != 2 {
|
||||
t.Errorf("expected 2 actions_add trace events, got %d", tracer.Count("actions_add"))
|
||||
}
|
||||
if !tracer.HasEvent("evaluation_complete") {
|
||||
t.Errorf("missing evaluation_complete trace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDayOfWeekAndTimeOfDay(t *testing.T) {
|
||||
// Wednesday 14:30 UTC
|
||||
now := time.Date(2024, 6, 12, 14, 30, 0, 0, time.UTC) // 2024-06-12 is Wednesday
|
||||
ctx := &PromotionEvalContext{
|
||||
Now: now,
|
||||
}
|
||||
|
||||
condDay := BaseCondition{
|
||||
ID: "dow",
|
||||
Type: CondDayOfWeek,
|
||||
Operator: OpIn,
|
||||
Value: cvStrings([]string{"wed", "fri"}),
|
||||
}
|
||||
condTime := BaseCondition{
|
||||
ID: "tod",
|
||||
Type: CondTimeOfDay,
|
||||
Operator: OpEquals, // operator is ignored for time-of-day internally
|
||||
Value: cvString("13:00-15:00"),
|
||||
}
|
||||
|
||||
rule := PromotionRule{
|
||||
ID: "day-time",
|
||||
Status: StatusActive,
|
||||
Priority: 1,
|
||||
StartDate: "2024-01-01",
|
||||
EndDate: nil,
|
||||
Conditions: Conditions{condDay, condTime},
|
||||
Actions: []Action{{ID: "a", Type: ActionPercentageDiscount, Value: 15}},
|
||||
}
|
||||
|
||||
tracer := &testTracer{}
|
||||
svc := NewPromotionService(tracer)
|
||||
res := svc.EvaluateRule(rule, ctx)
|
||||
if !res.Applicable {
|
||||
t.Fatalf("expected rule to apply for Wednesday 14:30 in window 13-15")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDateRangeCondition(t *testing.T) {
|
||||
now := time.Date(2024, 3, 15, 9, 0, 0, 0, time.UTC)
|
||||
ctx := &PromotionEvalContext{Now: now}
|
||||
|
||||
condRange := BaseCondition{
|
||||
ID: "date_range",
|
||||
Type: CondDateRange,
|
||||
Operator: OpEquals, // not used
|
||||
Value: cvStrings([]string{"2024-03-01", "2024-03-31"}),
|
||||
}
|
||||
|
||||
rule := PromotionRule{
|
||||
ID: "range",
|
||||
Status: StatusActive,
|
||||
Priority: 1,
|
||||
StartDate: "2024-01-01",
|
||||
EndDate: nil,
|
||||
Conditions: Conditions{condRange},
|
||||
Actions: []Action{{ID: "a", Type: ActionFixedDiscount, Value: 500}},
|
||||
}
|
||||
|
||||
tracer := &testTracer{}
|
||||
svc := NewPromotionService(tracer)
|
||||
res := svc.EvaluateRule(rule, ctx)
|
||||
if !res.Applicable {
|
||||
t.Fatalf("expected date range rule to apply for 2024-03-15")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Utilities -------------------------------------------------------------
|
||||
|
||||
func ptr(s string) *string { return &s }
|
||||
func ptrInt(i int) *int { return &i }
|
||||
443
pkg/promotions/type_test.go
Normal file
443
pkg/promotions/type_test.go
Normal file
@@ -0,0 +1,443 @@
|
||||
package promotions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// sampleJSON mirrors the user's full example data (all three rules)
|
||||
var sampleJSON = []byte(`[
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Summer Sale 2024",
|
||||
"description": "20% off on all summer items",
|
||||
"status": "active",
|
||||
"priority": 1,
|
||||
"startDate": "2024-06-01",
|
||||
"endDate": "2024-08-31",
|
||||
"conditions": [
|
||||
{
|
||||
"id": "group1",
|
||||
"type": "group",
|
||||
"operator": "AND",
|
||||
"conditions": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "product_category",
|
||||
"operator": "in",
|
||||
"value": ["summer", "beachwear"],
|
||||
"label": "Product category is Summer or Beachwear"
|
||||
},
|
||||
{
|
||||
"id": "c2",
|
||||
"type": "cart_total",
|
||||
"operator": "greater_or_equal",
|
||||
"value": 50,
|
||||
"label": "Cart total is at least $50"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"id": "a1",
|
||||
"type": "percentage_discount",
|
||||
"value": 20,
|
||||
"label": "20% discount"
|
||||
}
|
||||
],
|
||||
"usageLimit": 1000,
|
||||
"usageCount": 342,
|
||||
"createdAt": "2024-05-15T10:00:00Z",
|
||||
"updatedAt": "2024-05-20T14:30:00Z",
|
||||
"createdBy": "admin@example.com",
|
||||
"tags": ["seasonal", "summer"]
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "VIP Customer Exclusive",
|
||||
"description": "Free shipping for VIP customers",
|
||||
"status": "active",
|
||||
"priority": 2,
|
||||
"startDate": "2024-01-01",
|
||||
"endDate": null,
|
||||
"conditions": [
|
||||
{
|
||||
"id": "c3",
|
||||
"type": "customer_segment",
|
||||
"operator": "equals",
|
||||
"value": "vip",
|
||||
"label": "Customer segment is VIP"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"id": "a2",
|
||||
"type": "free_shipping",
|
||||
"value": 0,
|
||||
"label": "Free shipping"
|
||||
}
|
||||
],
|
||||
"usageCount": 1523,
|
||||
"createdAt": "2023-12-15T09:00:00Z",
|
||||
"updatedAt": "2024-01-05T11:20:00Z",
|
||||
"createdBy": "marketing@example.com",
|
||||
"tags": ["vip", "loyalty"]
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"name": "Buy 2 Get 1 Free",
|
||||
"description": "Buy 2 items, get the cheapest one free",
|
||||
"status": "scheduled",
|
||||
"priority": 3,
|
||||
"startDate": "2024-12-01",
|
||||
"endDate": "2024-12-25",
|
||||
"conditions": [
|
||||
{
|
||||
"id": "c4",
|
||||
"type": "item_quantity",
|
||||
"operator": "greater_or_equal",
|
||||
"value": 3,
|
||||
"label": "Cart has at least 3 items"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"id": "a3",
|
||||
"type": "buy_x_get_y",
|
||||
"value": 0,
|
||||
"config": { "buy": 2, "get": 1, "discount": 100 },
|
||||
"label": "Buy 2 Get 1 Free"
|
||||
}
|
||||
],
|
||||
"usageCount": 0,
|
||||
"createdAt": "2024-11-01T08:00:00Z",
|
||||
"updatedAt": "2024-11-01T08:00:00Z",
|
||||
"createdBy": "admin@example.com",
|
||||
"tags": ["holiday", "christmas"]
|
||||
}
|
||||
]`)
|
||||
|
||||
func TestDecodePromotionRulesBasic(t *testing.T) {
|
||||
rules, err := DecodePromotionRules(sampleJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePromotionRules failed: %v", err)
|
||||
}
|
||||
if len(rules) != 3 {
|
||||
t.Fatalf("expected 3 rules, got %d", len(rules))
|
||||
}
|
||||
|
||||
// Rule 1 checks
|
||||
r1 := rules[0]
|
||||
if r1.ID != "1" {
|
||||
t.Errorf("rule[0].ID = %s, want 1", r1.ID)
|
||||
}
|
||||
if r1.Status != StatusActive {
|
||||
t.Errorf("rule[0].Status = %s, want %s", r1.Status, StatusActive)
|
||||
}
|
||||
if r1.EndDate == nil || *r1.EndDate != "2024-08-31" {
|
||||
t.Errorf("rule[0].EndDate = %v, want 2024-08-31", r1.EndDate)
|
||||
}
|
||||
if r1.UsageLimit == nil || *r1.UsageLimit != 1000 {
|
||||
t.Errorf("rule[0].UsageLimit = %v, want 1000", r1.UsageLimit)
|
||||
}
|
||||
|
||||
// Rule 2 checks
|
||||
r2 := rules[1]
|
||||
if r2.ID != "2" {
|
||||
t.Errorf("rule[1].ID = %s, want 2", r2.ID)
|
||||
}
|
||||
if r2.EndDate != nil {
|
||||
t.Errorf("rule[1].EndDate should be nil (from null), got %v", *r2.EndDate)
|
||||
}
|
||||
if r2.UsageLimit != nil {
|
||||
t.Errorf("rule[1].UsageLimit should be nil (missing), got %v", *r2.UsageLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionDecoding(t *testing.T) {
|
||||
rules, err := DecodePromotionRules(sampleJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePromotionRules failed: %v", err)
|
||||
}
|
||||
r1 := rules[0]
|
||||
if len(r1.Conditions) != 1 {
|
||||
t.Fatalf("expected 1 top-level condition group, got %d", len(r1.Conditions))
|
||||
}
|
||||
|
||||
grp, ok := r1.Conditions[0].(ConditionGroup)
|
||||
if !ok {
|
||||
t.Fatalf("top-level condition is not a group, type=%T", r1.Conditions[0])
|
||||
}
|
||||
if grp.Operator != LogicAND {
|
||||
t.Errorf("group operator = %s, want AND", grp.Operator)
|
||||
}
|
||||
|
||||
if len(grp.Conditions) != 2 {
|
||||
t.Fatalf("expected 2 child conditions, got %d", len(grp.Conditions))
|
||||
}
|
||||
|
||||
// First child: product_category condition with slice value
|
||||
c0, ok := grp.Conditions[0].(BaseCondition)
|
||||
if !ok {
|
||||
t.Fatalf("first child not BaseCondition, got %T", grp.Conditions[0])
|
||||
}
|
||||
if c0.Type != CondProductCategory {
|
||||
t.Errorf("first child type = %s, want %s", c0.Type, CondProductCategory)
|
||||
}
|
||||
if c0.Operator != OpIn {
|
||||
t.Errorf("first child operator = %s, want %s", c0.Operator, OpIn)
|
||||
}
|
||||
if arr, ok := c0.Value.AsStringSlice(); !ok || len(arr) != 2 || arr[0] != "summer" {
|
||||
t.Errorf("expected string slice value [summer,...], got %v", arr)
|
||||
}
|
||||
|
||||
// Second child: cart_total numeric
|
||||
c1, ok := grp.Conditions[1].(BaseCondition)
|
||||
if !ok {
|
||||
t.Fatalf("second child not BaseCondition, got %T", grp.Conditions[1])
|
||||
}
|
||||
if c1.Type != CondCartTotal {
|
||||
t.Errorf("second child type = %s, want %s", c1.Type, CondCartTotal)
|
||||
}
|
||||
if val, ok := c1.Value.AsFloat64(); !ok || val != 50 {
|
||||
t.Errorf("expected numeric value 50, got %v (ok=%v)", val, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionValueHelpers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
jsonVal string
|
||||
wantStr string
|
||||
wantNum *float64
|
||||
wantSS []string
|
||||
wantFS []float64
|
||||
}{
|
||||
{
|
||||
name: "string value",
|
||||
jsonVal: `"vip"`,
|
||||
wantStr: "vip",
|
||||
},
|
||||
{
|
||||
name: "number value",
|
||||
jsonVal: `42`,
|
||||
wantNum: floatPtr(42),
|
||||
},
|
||||
{
|
||||
name: "string slice",
|
||||
jsonVal: `["a","b"]`,
|
||||
wantSS: []string{"a", "b"},
|
||||
},
|
||||
{
|
||||
name: "number slice int",
|
||||
jsonVal: `[1,2,3]`,
|
||||
wantFS: []float64{1, 2, 3},
|
||||
},
|
||||
{
|
||||
name: "number slice float",
|
||||
jsonVal: `[1.5,2.25]`,
|
||||
wantFS: []float64{1.5, 2.25},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var cv ConditionValue
|
||||
if err := json.Unmarshal([]byte(tc.jsonVal), &cv); err != nil {
|
||||
t.Fatalf("unmarshal value failed: %v", err)
|
||||
}
|
||||
if tc.wantStr != "" {
|
||||
if got, ok := cv.AsString(); !ok || got != tc.wantStr {
|
||||
t.Errorf("AsString got=%q ok=%v want=%q", got, ok, tc.wantStr)
|
||||
}
|
||||
}
|
||||
if tc.wantNum != nil {
|
||||
if got, ok := cv.AsFloat64(); !ok || got != *tc.wantNum {
|
||||
t.Errorf("AsFloat64 got=%v ok=%v want=%v", got, ok, *tc.wantNum)
|
||||
}
|
||||
}
|
||||
if tc.wantSS != nil {
|
||||
if got, ok := cv.AsStringSlice(); !ok || len(got) != len(tc.wantSS) || got[0] != tc.wantSS[0] {
|
||||
t.Errorf("AsStringSlice got=%v ok=%v want=%v", got, ok, tc.wantSS)
|
||||
}
|
||||
}
|
||||
if tc.wantFS != nil {
|
||||
if got, ok := cv.AsFloat64Slice(); !ok || len(got) != len(tc.wantFS) {
|
||||
t.Errorf("AsFloat64Slice got=%v ok=%v want=%v", got, ok, tc.wantFS)
|
||||
} else {
|
||||
for i := range got {
|
||||
if got[i] != tc.wantFS[i] {
|
||||
t.Errorf("AsFloat64Slice[%d]=%v want=%v", i, got[i], tc.wantFS[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkConditionsTraversal(t *testing.T) {
|
||||
rules, err := DecodePromotionRules(sampleJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePromotionRules failed: %v", err)
|
||||
}
|
||||
visited := 0
|
||||
WalkConditions(rules[0].Conditions, func(c Condition) bool {
|
||||
visited++
|
||||
return true
|
||||
})
|
||||
// group + 2 children
|
||||
if visited != 3 {
|
||||
t.Errorf("expected 3 visited conditions, got %d", visited)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionBundleConfigParsing(t *testing.T) {
|
||||
jsonData := []byte(`[
|
||||
{
|
||||
"id": "bundle-1",
|
||||
"name": "Bundle Deal",
|
||||
"description": "Fixed price bundle",
|
||||
"status": "active",
|
||||
"priority": 1,
|
||||
"startDate": "2024-01-01",
|
||||
"endDate": null,
|
||||
"conditions": [],
|
||||
"actions": [
|
||||
{
|
||||
"id": "act-bundle",
|
||||
"type": "bundle_discount",
|
||||
"value": 0,
|
||||
"bundleConfig": {
|
||||
"containers": [
|
||||
{
|
||||
"id": "cont1",
|
||||
"name": "Shoes",
|
||||
"quantity": 2,
|
||||
"selectionType": "any",
|
||||
"qualifyingRules": {
|
||||
"type": "category",
|
||||
"value": "shoes"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cont2",
|
||||
"name": "Socks",
|
||||
"quantity": 3,
|
||||
"selectionType": "specific",
|
||||
"qualifyingRules": {
|
||||
"type": "product_ids",
|
||||
"value": ["sock-1", "sock-2"]
|
||||
},
|
||||
"allowedProducts": ["sock-1","sock-2"]
|
||||
}
|
||||
],
|
||||
"pricing": {
|
||||
"type": "fixed_price",
|
||||
"value": 49.99
|
||||
},
|
||||
"requireAllContainers": true
|
||||
},
|
||||
"config": { "note": "Bundle applies to footwear + socks" }
|
||||
}
|
||||
],
|
||||
"usageCount": 0,
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"updatedAt": "2024-01-01T00:00:00Z",
|
||||
"createdBy": "bundle@example.com",
|
||||
"tags": ["bundle","footwear"]
|
||||
}
|
||||
]`)
|
||||
rules, err := DecodePromotionRules(jsonData)
|
||||
if err != nil {
|
||||
t.Fatalf("decode failed: %v", err)
|
||||
}
|
||||
if len(rules) != 1 {
|
||||
t.Fatalf("expected 1 rule, got %d", len(rules))
|
||||
}
|
||||
if len(rules[0].Actions) != 1 {
|
||||
t.Fatalf("expected 1 action, got %d", len(rules[0].Actions))
|
||||
}
|
||||
act := rules[0].Actions[0]
|
||||
if act.Type != ActionBundleDiscount {
|
||||
t.Fatalf("action type = %s, want %s", act.Type, ActionBundleDiscount)
|
||||
}
|
||||
if act.BundleConfig == nil {
|
||||
t.Fatalf("bundleConfig nil")
|
||||
}
|
||||
if act.BundleConfig.Pricing.Type != "fixed_price" {
|
||||
t.Errorf("pricing.type = %s, want fixed_price", act.BundleConfig.Pricing.Type)
|
||||
}
|
||||
if act.BundleConfig.Pricing.Value != 49.99 {
|
||||
t.Errorf("pricing.value = %v, want 49.99", act.BundleConfig.Pricing.Value)
|
||||
}
|
||||
if !act.BundleConfig.RequireAllContainers {
|
||||
t.Errorf("RequireAllContainers expected true")
|
||||
}
|
||||
if len(act.BundleConfig.Containers) != 2 {
|
||||
t.Fatalf("expected 2 containers, got %d", len(act.BundleConfig.Containers))
|
||||
}
|
||||
if act.Config == nil || act.Config["note"] != "Bundle applies to footwear + socks" {
|
||||
t.Errorf("config.note mismatch: %v", act.Config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionValueInvalidTypes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{"object", `{}`},
|
||||
{"booleanTrue", `true`},
|
||||
{"booleanFalse", `false`},
|
||||
{"null", `null`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var cv ConditionValue
|
||||
if err := json.Unmarshal([]byte(tc.raw), &cv); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
if s, ok := cv.AsString(); ok {
|
||||
t.Errorf("AsString unexpectedly succeeded (%q) for %s", s, tc.name)
|
||||
}
|
||||
if n, ok := cv.AsFloat64(); ok {
|
||||
t.Errorf("AsFloat64 unexpectedly succeeded (%v) for %s", n, tc.name)
|
||||
}
|
||||
if ss, ok := cv.AsStringSlice(); ok {
|
||||
t.Errorf("AsStringSlice unexpectedly succeeded (%v) for %s", ss, tc.name)
|
||||
}
|
||||
if fs, ok := cv.AsFloat64Slice(); ok {
|
||||
t.Errorf("AsFloat64Slice unexpectedly succeeded (%v) for %s", fs, tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromotionRuleRoundTrip(t *testing.T) {
|
||||
orig, err := DecodePromotionRules(sampleJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("initial decode failed: %v", err)
|
||||
}
|
||||
data, err := json.Marshal(orig)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal failed: %v", err)
|
||||
}
|
||||
round, err := DecodePromotionRules(data)
|
||||
if err != nil {
|
||||
t.Fatalf("round-trip decode failed: %v", err)
|
||||
}
|
||||
if len(orig) != len(round) {
|
||||
t.Fatalf("rule count mismatch: orig=%d round=%d", len(orig), len(round))
|
||||
}
|
||||
// spot-check first rule
|
||||
if orig[0].Name != round[0].Name {
|
||||
t.Errorf("first rule name mismatch: %s vs %s", orig[0].Name, round[0].Name)
|
||||
}
|
||||
if len(orig[0].Conditions) != len(round[0].Conditions) {
|
||||
t.Errorf("first rule condition count mismatch: %d vs %d", len(orig[0].Conditions), len(round[0].Conditions))
|
||||
}
|
||||
}
|
||||
|
||||
func floatPtr(f float64) *float64 { return &f }
|
||||
413
pkg/promotions/types.go
Normal file
413
pkg/promotions/types.go
Normal file
@@ -0,0 +1,413 @@
|
||||
package promotions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// -----------------------------
|
||||
// Enum-like string types
|
||||
// -----------------------------
|
||||
|
||||
type ConditionOperator string
|
||||
|
||||
const (
|
||||
OpEquals ConditionOperator = "="
|
||||
OpNotEquals ConditionOperator = "!="
|
||||
OpGreaterThan ConditionOperator = ">"
|
||||
OpLessThan ConditionOperator = "<"
|
||||
OpGreaterOrEqual ConditionOperator = ">="
|
||||
OpLessOrEqual ConditionOperator = "<="
|
||||
OpContains ConditionOperator = "contains"
|
||||
OpNotContains ConditionOperator = "not_contains"
|
||||
OpIn ConditionOperator = "in"
|
||||
OpNotIn ConditionOperator = "not_in"
|
||||
)
|
||||
|
||||
type ConditionType string
|
||||
|
||||
const (
|
||||
CondCartTotal ConditionType = "cart_total"
|
||||
CondItemQuantity ConditionType = "item_quantity"
|
||||
CondCustomerSegment ConditionType = "customer_segment"
|
||||
CondProductCategory ConditionType = "product_category"
|
||||
CondProductID ConditionType = "product_id"
|
||||
CondCustomerLifetime ConditionType = "customer_lifetime_value"
|
||||
CondOrderCount ConditionType = "order_count"
|
||||
CondDateRange ConditionType = "date_range"
|
||||
CondDayOfWeek ConditionType = "day_of_week"
|
||||
CondTimeOfDay ConditionType = "time_of_day"
|
||||
CondGroup ConditionType = "group" // synthetic value for groups
|
||||
)
|
||||
|
||||
type ActionType string
|
||||
|
||||
const (
|
||||
ActionPercentageDiscount ActionType = "percentage_discount"
|
||||
ActionFixedDiscount ActionType = "fixed_discount"
|
||||
ActionFreeShipping ActionType = "free_shipping"
|
||||
ActionBuyXGetY ActionType = "buy_x_get_y"
|
||||
ActionTieredDiscount ActionType = "tiered_discount"
|
||||
ActionBundleDiscount ActionType = "bundle_discount"
|
||||
)
|
||||
|
||||
type LogicOperator string
|
||||
|
||||
const (
|
||||
LogicAND LogicOperator = "&&"
|
||||
LogicOR LogicOperator = "||"
|
||||
)
|
||||
|
||||
type PromotionStatus string
|
||||
|
||||
const (
|
||||
StatusActive PromotionStatus = "active"
|
||||
StatusInactive PromotionStatus = "inactive"
|
||||
StatusScheduled PromotionStatus = "scheduled"
|
||||
StatusExpired PromotionStatus = "expired"
|
||||
)
|
||||
|
||||
// -----------------------------
|
||||
// Condition Value (union)
|
||||
// -----------------------------
|
||||
//
|
||||
// Represents: string | number | []string | []number
|
||||
// We store raw JSON and lazily interpret.
|
||||
|
||||
type ConditionValue struct {
|
||||
Raw json.RawMessage
|
||||
}
|
||||
|
||||
func (cv *ConditionValue) UnmarshalJSON(b []byte) error {
|
||||
if len(b) == 0 {
|
||||
return errors.New("empty ConditionValue")
|
||||
}
|
||||
// Just store raw; interpretation happens via helpers.
|
||||
cv.Raw = append(cv.Raw[0:0], b...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helpers to interpret value:
|
||||
func (cv ConditionValue) AsString() (string, bool) {
|
||||
// Treat explicit JSON null as invalid
|
||||
if string(cv.Raw) == "null" {
|
||||
return "", false
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(cv.Raw, &s); err == nil {
|
||||
return s, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (cv ConditionValue) AsFloat64() (float64, bool) {
|
||||
if string(cv.Raw) == "null" {
|
||||
return 0, false
|
||||
}
|
||||
var f float64
|
||||
if err := json.Unmarshal(cv.Raw, &f); err == nil {
|
||||
return f, true
|
||||
}
|
||||
// Attempt integer decode into float64
|
||||
var i int64
|
||||
if err := json.Unmarshal(cv.Raw, &i); err == nil {
|
||||
return float64(i), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func (cv ConditionValue) AsStringSlice() ([]string, bool) {
|
||||
if string(cv.Raw) == "null" {
|
||||
return nil, false
|
||||
}
|
||||
var arr []string
|
||||
if err := json.Unmarshal(cv.Raw, &arr); err == nil {
|
||||
return arr, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (cv ConditionValue) AsFloat64Slice() ([]float64, bool) {
|
||||
if string(cv.Raw) == "null" {
|
||||
return nil, false
|
||||
}
|
||||
var arrNum []float64
|
||||
if err := json.Unmarshal(cv.Raw, &arrNum); err == nil {
|
||||
return arrNum, true
|
||||
}
|
||||
// Try []int -> []float64
|
||||
var arrInt []int64
|
||||
if err := json.Unmarshal(cv.Raw, &arrInt); err == nil {
|
||||
out := make([]float64, len(arrInt))
|
||||
for i, v := range arrInt {
|
||||
out[i] = float64(v)
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (cv ConditionValue) String() string {
|
||||
if s, ok := cv.AsString(); ok {
|
||||
return s
|
||||
}
|
||||
if f, ok := cv.AsFloat64(); ok {
|
||||
return strconv.FormatFloat(f, 'f', -1, 64)
|
||||
}
|
||||
if ss, ok := cv.AsStringSlice(); ok {
|
||||
return fmt.Sprintf("%v", ss)
|
||||
}
|
||||
if fs, ok := cv.AsFloat64Slice(); ok {
|
||||
return fmt.Sprintf("%v", fs)
|
||||
}
|
||||
return string(cv.Raw)
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// BaseCondition
|
||||
// -----------------------------
|
||||
|
||||
type BaseCondition struct {
|
||||
ID string `json:"id"`
|
||||
Type ConditionType `json:"type"`
|
||||
Operator ConditionOperator `json:"operator"`
|
||||
Value ConditionValue `json:"value"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
func (b BaseCondition) IsGroup() bool { return false }
|
||||
|
||||
// -----------------------------
|
||||
// ConditionGroup
|
||||
// -----------------------------
|
||||
|
||||
type ConditionGroup struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // always "group"
|
||||
Operator LogicOperator `json:"operator"`
|
||||
Conditions Conditions `json:"conditions"`
|
||||
}
|
||||
|
||||
// Custom unmarshaller ensures nested polymorphic conditions are decoded
|
||||
// using the Conditions type (which applies the raw element discriminator).
|
||||
func (g *ConditionGroup) UnmarshalJSON(b []byte) error {
|
||||
type alias struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Operator LogicOperator `json:"operator"`
|
||||
Conditions json.RawMessage `json:"conditions"`
|
||||
}
|
||||
var a alias
|
||||
if err := json.Unmarshal(b, &a); err != nil {
|
||||
return err
|
||||
}
|
||||
// Basic validation
|
||||
if a.Type != "group" {
|
||||
return fmt.Errorf("ConditionGroup expected type 'group', got %q", a.Type)
|
||||
}
|
||||
|
||||
var conds Conditions
|
||||
if len(a.Conditions) > 0 {
|
||||
if err := json.Unmarshal(a.Conditions, &conds); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
g.ID = a.ID
|
||||
g.Type = a.Type
|
||||
switch strings.ToLower(string(a.Operator)) {
|
||||
case "and":
|
||||
g.Operator = LogicAND
|
||||
case "or":
|
||||
g.Operator = LogicOR
|
||||
default:
|
||||
g.Operator = a.Operator
|
||||
}
|
||||
g.Conditions = conds
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g ConditionGroup) IsGroup() bool { return true }
|
||||
|
||||
// -----------------------------
|
||||
// Condition interface + slice
|
||||
// -----------------------------
|
||||
|
||||
type Condition interface {
|
||||
IsGroup() bool
|
||||
}
|
||||
|
||||
// Internal wrapper to help decode each element.
|
||||
type rawCond struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// Custom unmarshaler for a Condition slice
|
||||
type Conditions []Condition
|
||||
|
||||
func (cs *Conditions) UnmarshalJSON(b []byte) error {
|
||||
var rawList []json.RawMessage
|
||||
if err := json.Unmarshal(b, &rawList); err != nil {
|
||||
return err
|
||||
}
|
||||
out := make([]Condition, 0, len(rawList))
|
||||
for _, elem := range rawList {
|
||||
var hdr rawCond
|
||||
if err := json.Unmarshal(elem, &hdr); err != nil {
|
||||
return err
|
||||
}
|
||||
if hdr.Type == "group" {
|
||||
var grp ConditionGroup
|
||||
if err := json.Unmarshal(elem, &grp); err != nil {
|
||||
return err
|
||||
}
|
||||
// Recursively decode grp.Conditions (already handled because field type is []Condition)
|
||||
out = append(out, grp)
|
||||
} else {
|
||||
var bc BaseCondition
|
||||
if err := json.Unmarshal(elem, &bc); err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, bc)
|
||||
}
|
||||
}
|
||||
*cs = out
|
||||
return nil
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Bundle Structures
|
||||
// -----------------------------
|
||||
|
||||
type BundleQualifyingRules struct {
|
||||
Type string `json:"type"` // "category" | "product_ids" | "tag" | "all"
|
||||
Value interface{} `json:"value"` // string or []string
|
||||
}
|
||||
|
||||
type BundleContainer struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Quantity int `json:"quantity"`
|
||||
SelectionType string `json:"selectionType"` // "any" | "specific"
|
||||
QualifyingRules BundleQualifyingRules `json:"qualifyingRules"`
|
||||
AllowedProducts []string `json:"allowedProducts,omitempty"`
|
||||
}
|
||||
|
||||
type BundlePricing struct {
|
||||
Type string `json:"type"` // "fixed_price" | "percentage_discount" | "fixed_discount"
|
||||
Value float64 `json:"value"`
|
||||
}
|
||||
|
||||
type BundleConfig struct {
|
||||
Containers []BundleContainer `json:"containers"`
|
||||
Pricing BundlePricing `json:"pricing"`
|
||||
RequireAllContainers bool `json:"requireAllContainers"`
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Action
|
||||
// -----------------------------
|
||||
|
||||
type Action struct {
|
||||
ID string `json:"id"`
|
||||
Type ActionType `json:"type"`
|
||||
Value interface{} `json:"value"` // number or string
|
||||
Config map[string]interface{} `json:"config,omitempty"`
|
||||
BundleConfig *BundleConfig `json:"bundleConfig,omitempty"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Promotion Rule
|
||||
// -----------------------------
|
||||
|
||||
type PromotionRule struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status PromotionStatus `json:"status"`
|
||||
Priority int `json:"priority"`
|
||||
StartDate string `json:"startDate"`
|
||||
EndDate *string `json:"endDate"` // null -> nil
|
||||
Conditions Conditions `json:"conditions"`
|
||||
Actions []Action `json:"actions"`
|
||||
UsageLimit *int `json:"usageLimit,omitempty"`
|
||||
UsageCount int `json:"usageCount"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Promotion Stats
|
||||
// -----------------------------
|
||||
|
||||
type PromotionStats struct {
|
||||
TotalPromotions int `json:"totalPromotions"`
|
||||
ActivePromotions int `json:"activePromotions"`
|
||||
TotalRevenue float64 `json:"totalRevenue"`
|
||||
TotalOrders int `json:"totalOrders"`
|
||||
AverageDiscount float64 `json:"averageDiscount"`
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Utility: Decode array of rules
|
||||
// -----------------------------
|
||||
|
||||
func DecodePromotionRules(data []byte) ([]PromotionRule, error) {
|
||||
var rules []PromotionRule
|
||||
if err := json.Unmarshal(data, &rules); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// Example helper to inspect conditions programmatically.
|
||||
func WalkConditions(conds []Condition, fn func(c Condition) bool) {
|
||||
for _, c := range conds {
|
||||
if !fn(c) {
|
||||
return
|
||||
}
|
||||
if grp, ok := c.(ConditionGroup); ok {
|
||||
WalkConditions(grp.Conditions, fn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type PromotionState struct {
|
||||
Promotions []PromotionRule `json:"promotions"`
|
||||
}
|
||||
|
||||
type StateFile struct {
|
||||
State PromotionState `json:"state"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
func (sf *StateFile) GetPromotion(id string) (*PromotionRule, bool) {
|
||||
for _, v := range sf.State.Promotions {
|
||||
if v.ID == id {
|
||||
return &v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func LoadStateFile(fileName string) (*StateFile, error) {
|
||||
f, err := os.Open(fileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
dec := json.NewDecoder(f)
|
||||
sf := &StateFile{}
|
||||
err = dec.Decode(sf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sf, nil
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
"time"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
@@ -26,6 +29,14 @@ type RemoteHost struct {
|
||||
missedPings int
|
||||
}
|
||||
|
||||
const name = "proxy"
|
||||
|
||||
var (
|
||||
tracer = otel.Tracer(name)
|
||||
meter = otel.Meter(name)
|
||||
logger = otelslog.NewLogger(name)
|
||||
)
|
||||
|
||||
func NewRemoteHost(host string) (*RemoteHost, error) {
|
||||
|
||||
target := fmt.Sprintf("%s:1337", host)
|
||||
@@ -49,7 +60,7 @@ func NewRemoteHost(host string) (*RemoteHost, error) {
|
||||
|
||||
return &RemoteHost{
|
||||
host: host,
|
||||
httpBase: fmt.Sprintf("http://%s:8080/cart", host),
|
||||
httpBase: fmt.Sprintf("http://%s:8080", host),
|
||||
conn: conn,
|
||||
transport: transport,
|
||||
client: client,
|
||||
@@ -146,8 +157,20 @@ func (h *RemoteHost) AnnounceExpiry(uids []uint64) {
|
||||
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)
|
||||
ctx, span := tracer.Start(r.Context(), "remote_proxy")
|
||||
defer span.End()
|
||||
span.SetAttributes(
|
||||
attribute.String("component", "proxy"),
|
||||
attribute.String("cartid", fmt.Sprintf("%d", id)),
|
||||
attribute.String("host", h.host),
|
||||
attribute.String("method", r.Method),
|
||||
attribute.String("target", target),
|
||||
)
|
||||
logger.InfoContext(ctx, "proxying request", "cartid", id, "host", h.host, "method", r.Method)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, r.Method, target, r.Body)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
http.Error(w, "proxy build error", http.StatusBadGateway)
|
||||
return false, err
|
||||
}
|
||||
@@ -161,25 +184,27 @@ func (h *RemoteHost) Proxy(id uint64, w http.ResponseWriter, r *http.Request) (b
|
||||
}
|
||||
res, err := h.client.Do(req)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
http.Error(w, "proxy request error", http.StatusBadGateway)
|
||||
return false, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
span.SetAttributes(attribute.Int("status_code", res.StatusCode))
|
||||
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 {
|
||||
span.RecordError(copyErr)
|
||||
return true, copyErr
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
return false, fmt.Errorf("proxy response status %d", res.StatusCode)
|
||||
|
||||
}
|
||||
|
||||
func (r *RemoteHost) IsHealthy() bool {
|
||||
|
||||
@@ -3,6 +3,7 @@ package voucher
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
@@ -137,12 +138,7 @@ func (rs *RuleSet) Applies(ctx EvalContext) bool {
|
||||
|
||||
// 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
|
||||
return slices.ContainsFunc(items, pred)
|
||||
}
|
||||
|
||||
// ParseRules parses a rule expression into a RuleSet.
|
||||
|
||||
@@ -28,6 +28,32 @@ func TestParseRules_SimpleSku(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleCartTotal(t *testing.T) {
|
||||
rs, err := ParseRules("min_total>=500000")
|
||||
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 != RuleMinTotal {
|
||||
t.Fatalf("expected kind cart total got %s", c.Kind)
|
||||
}
|
||||
ctx := EvalContext{
|
||||
Items: []Item{
|
||||
Item{
|
||||
Sku: "123",
|
||||
},
|
||||
},
|
||||
CartTotalInc: 400000,
|
||||
}
|
||||
applied := rs.Applies(ctx)
|
||||
if applied {
|
||||
t.Fatalf("expected")
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package voucher
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
@@ -13,15 +16,13 @@ type Rule struct {
|
||||
|
||||
type Voucher struct {
|
||||
Code string `json:"code"`
|
||||
Value int64 `json:"discount"`
|
||||
TaxValue int64 `json:"taxValue"`
|
||||
TaxRate int `json:"taxRate"`
|
||||
rules []Rule `json:"rules"`
|
||||
Value int64 `json:"value"`
|
||||
Rules string `json:"rules"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
// Add fields here
|
||||
|
||||
}
|
||||
|
||||
var ErrInvalidCode = errors.New("invalid vouchercode")
|
||||
@@ -30,10 +31,54 @@ func (s *Service) GetVoucher(code string) (*messages.AddVoucher, error) {
|
||||
if code == "" {
|
||||
return nil, ErrInvalidCode
|
||||
}
|
||||
value := int64(250_00)
|
||||
sf, err := LoadStateFile("data/vouchers.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v, ok := sf.GetVoucher(code)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no voucher found for code: %s", code)
|
||||
}
|
||||
|
||||
return &messages.AddVoucher{
|
||||
Code: code,
|
||||
Value: value,
|
||||
VoucherRules: make([]*messages.VoucherRule, 0),
|
||||
Value: v.Value,
|
||||
Description: v.Description,
|
||||
VoucherRules: []string{
|
||||
v.Rules,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type State struct {
|
||||
Vouchers []Voucher `json:"vouchers"`
|
||||
}
|
||||
|
||||
type StateFile struct {
|
||||
State State `json:"state"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
func (sf *StateFile) GetVoucher(code string) (*Voucher, bool) {
|
||||
for _, v := range sf.State.Vouchers {
|
||||
if v.Code == code {
|
||||
return &v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func LoadStateFile(fileName string) (*StateFile, error) {
|
||||
f, err := os.Open(fileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
dec := json.NewDecoder(f)
|
||||
sf := &StateFile{}
|
||||
err = dec.Decode(sf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sf, nil
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ syntax = "proto3";
|
||||
package messages;
|
||||
option go_package = "git.tornberg.me/go-cart-actor/proto;messages";
|
||||
|
||||
message ClearCartRequest {
|
||||
import "google/protobuf/any.proto";
|
||||
|
||||
}
|
||||
message ClearCartRequest {}
|
||||
|
||||
message AddItem {
|
||||
uint32 item_id = 1;
|
||||
@@ -27,14 +27,13 @@ message AddItem {
|
||||
string sellerId = 19;
|
||||
string sellerName = 20;
|
||||
string country = 21;
|
||||
string saleStatus = 24;
|
||||
optional string outlet = 12;
|
||||
optional string storeId = 22;
|
||||
optional uint32 parentId = 23;
|
||||
}
|
||||
|
||||
message RemoveItem {
|
||||
uint32 Id = 1;
|
||||
}
|
||||
message RemoveItem { uint32 Id = 1; }
|
||||
|
||||
message ChangeQuantity {
|
||||
uint32 Id = 1;
|
||||
@@ -70,9 +69,7 @@ message PickupPoint {
|
||||
optional string country = 6;
|
||||
}
|
||||
|
||||
message RemoveDelivery {
|
||||
uint32 id = 1;
|
||||
}
|
||||
message RemoveDelivery { uint32 id = 1; }
|
||||
|
||||
message CreateCheckoutOrder {
|
||||
string terms = 1;
|
||||
@@ -98,19 +95,30 @@ message InitializeCheckout {
|
||||
bool paymentInProgress = 3;
|
||||
}
|
||||
|
||||
message VoucherRule {
|
||||
string type = 2;
|
||||
string description = 3;
|
||||
string condition = 4;
|
||||
string action = 5;
|
||||
message InventoryReserved {
|
||||
string id = 1;
|
||||
string status = 2;
|
||||
optional string message = 3;
|
||||
}
|
||||
|
||||
message AddVoucher {
|
||||
string code = 1;
|
||||
int64 value = 2;
|
||||
repeated VoucherRule voucherRules = 3;
|
||||
repeated string voucherRules = 3;
|
||||
string description = 4;
|
||||
}
|
||||
|
||||
message RemoveVoucher {
|
||||
uint32 id = 1;
|
||||
message RemoveVoucher { uint32 id = 1; }
|
||||
|
||||
message UpsertSubscriptionDetails {
|
||||
optional string id = 1;
|
||||
string offeringCode = 2;
|
||||
string signingType = 3;
|
||||
google.protobuf.Any data = 4;
|
||||
}
|
||||
|
||||
message PreConditionFailed {
|
||||
string operation = 1;
|
||||
string error = 2;
|
||||
google.protobuf.Any input = 3;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user