Author SHA1 Message Date
mats 5e36af2524 wip 2025-12-04 22:09:26 +01:00
170 changed files with 1398 additions and 20814 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: amd64 runs-on: amd64
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Build amd64 image. - name: Build amd64 image
run: | run: |
docker build \ docker build \
--progress=plain \ --progress=plain \
+6 -28
View File
@@ -22,10 +22,7 @@
############################ ############################
# Build Stage # Build Stage
############################ ############################
# Run the Go toolchain on the NATIVE build platform and cross-compile to the FROM golang:1.25-alpine AS build
# target via GOOS/GOARCH below — avoids emulating an amd64 toolchain on arm64
# hosts (QEMU segfaults during module download). No-op when build==target.
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build
WORKDIR /src WORKDIR /src
RUN apk add --no-cache git RUN apk add --no-cache git
@@ -41,17 +38,12 @@ ARG TARGETOS
ARG TARGETARCH ARG TARGETARCH
ENV CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} ENV CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH}
# Sibling module sources, supplied as BuildKit named contexts (see # Dependency caching
# deploy/docker-compose.yml additional_contexts + deploy/k8s/build-push.sh COPY go.mod go.sum ./
# --build-context). They are resolved locally via the go.work written below, RUN go mod download
# because the renamed git.k6n.net/mats/* modules are not yet published.
COPY --from=slaskfinder . ./slask-finder
COPY --from=redisinventory . ./go-redis-inventory
# This module's source (relay on .dockerignore to prune). # Copy full source (relay on .dockerignore to prune)
COPY . ./go-cart-actor COPY . .
RUN printf 'go 1.26.2\n\nuse (\n\t.\n\t../slask-finder\n\t../go-redis-inventory\n)\n' > ./go-cart-actor/go.work
WORKDIR /src/go-cart-actor
# (Optional) If you do NOT check in generated protobuf code, uncomment generation: # (Optional) If you do NOT check in generated protobuf code, uncomment generation:
# RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest && \ # RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest && \
@@ -85,18 +77,6 @@ RUN go build -trimpath -ldflags="-s -w \
-X main.BuildDate=${BUILD_DATE}" \ -X main.BuildDate=${BUILD_DATE}" \
-o /out/go-checkout-actor ./cmd/checkout -o /out/go-checkout-actor ./cmd/checkout
RUN go build -trimpath -ldflags="-s -w \
-X main.Version=${VERSION} \
-X main.GitCommit=${GIT_COMMIT} \
-X main.BuildDate=${BUILD_DATE}" \
-o /out/go-order-actor ./cmd/order
RUN go build -trimpath -ldflags="-s -w \
-X main.Version=${VERSION} \
-X main.GitCommit=${GIT_COMMIT} \
-X main.BuildDate=${BUILD_DATE}" \
-o /out/go-profile-actor ./cmd/profile
############################ ############################
# Runtime Stage # Runtime Stage
############################ ############################
@@ -108,8 +88,6 @@ COPY --from=build /out/go-cart-actor /go-cart-actor
COPY --from=build /out/go-checkout-actor /go-checkout-actor COPY --from=build /out/go-checkout-actor /go-checkout-actor
COPY --from=build /out/go-cart-backoffice /go-cart-backoffice COPY --from=build /out/go-cart-backoffice /go-cart-backoffice
COPY --from=build /out/go-cart-inventory /go-cart-inventory COPY --from=build /out/go-cart-inventory /go-cart-inventory
COPY --from=build /out/go-order-actor /go-order-actor
COPY --from=build /out/go-profile-actor /go-profile-actor
# Document (not expose forcibly) typical ports: 8080 (HTTP), 1337 (gRPC) # Document (not expose forcibly) typical ports: 8080 (HTTP), 1337 (gRPC)
EXPOSE 8080 1337 EXPOSE 8080 1337
+14 -14
View File
@@ -14,16 +14,15 @@
# Conventions: # Conventions:
# - All .proto files live in $(PROTO_DIR) # - All .proto files live in $(PROTO_DIR)
# - Generated Go code is emitted under $(PROTO_DIR) via go_package mapping # - Generated Go code is emitted under $(PROTO_DIR) via go_package mapping
# - go_package is set to: git.k6n.net/mats/go-cart-actor/proto;messages # - go_package is set to: git.k6n.net/go-cart-actor/proto;messages
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
MODULE_PATH := git.k6n.net/mats/go-cart-actor MODULE_PATH := git.k6n.net/go-cart-actor
PROTO_DIR := proto PROTO_DIR := proto
PROTOS := $(PROTO_DIR)/cart.proto $(PROTO_DIR)/control_plane.proto $(PROTO_DIR)/checkout.proto $(PROTO_DIR)/order.proto PROTOS := $(PROTO_DIR)/cart.proto $(PROTO_DIR)/control_plane.proto $(PROTO_DIR)/checkout.proto
CART_PROTO_DIR := $(PROTO_DIR)/cart CART_PROTO_DIR := $(PROTO_DIR)/cart
CONTROL_PROTO_DIR := $(PROTO_DIR)/control CONTROL_PROTO_DIR := $(PROTO_DIR)/control
CHECKOUT_PROTO_DIR := $(PROTO_DIR)/checkout CHECKOUT_PROTO_DIR := $(PROTO_DIR)/checkout
ORDER_PROTO_DIR := $(PROTO_DIR)/order
# Allow override: make PROTOC=/path/to/protoc # Allow override: make PROTOC=/path/to/protoc
PROTOC ?= protoc PROTOC ?= protoc
@@ -84,10 +83,6 @@ protogen: check_tools
--go_out=./proto/checkout --go_opt=paths=source_relative \ --go_out=./proto/checkout --go_opt=paths=source_relative \
--go-grpc_out=./proto/checkout --go-grpc_opt=paths=source_relative \ --go-grpc_out=./proto/checkout --go-grpc_opt=paths=source_relative \
$(PROTO_DIR)/checkout.proto $(PROTO_DIR)/checkout.proto
$(PROTOC) -I $(PROTO_DIR) \
--go_out=./proto/order --go_opt=paths=source_relative \
--go-grpc_out=./proto/order --go-grpc_opt=paths=source_relative \
$(PROTO_DIR)/order.proto
@echo "$(GREEN)Protobuf generation complete.$(RESET)" @echo "$(GREEN)Protobuf generation complete.$(RESET)"
clean_proto: clean_proto:
@@ -106,12 +101,17 @@ verify_proto:
fi fi
@echo "$(GREEN)Proto layout OK (no root-level *.pb.go files).$(RESET)" @echo "$(GREEN)Proto layout OK (no root-level *.pb.go files).$(RESET)"
run:
REDIS_ADDRESS=10.10.3.18:6379 \
REDIS_PASSWORD=slaskredis \
CART_DEBUG_PORT=8091 \
CART_PORT=8090 \
go run ./cmd/cart/
tidy: tidy:
@echo "$(YELLOW)Running go mod tidy...$(RESET)" @echo "$(YELLOW)Running go mod tidy...$(RESET)"
-291
View File
@@ -1,291 +0,0 @@
#!/usr/bin/env bash
# ──────────────────────────────────────────────────────────────────────────────
# UCP API test suite
# Tests the Universal Commerce Protocol REST endpoints (cart, order) via curl.
#
# Prerequisites:
# make up-infra (infra compose up — order :8092, cart :8090)
# OR full stack (docker compose --profile "*" up — nginx on :8080)
#
# Usage:
# # Direct to Docker services (infra stack):
# bash api-tests/test_ucp.sh --infra
#
# # Via nginx (full compose stack):
# bash api-tests/test_ucp.sh --edge
#
# # Single service:
# bash api-tests/test_ucp.sh --order
# bash api-tests/test_ucp.sh --cart
#
# # Verify signatures (requires signing key mounted):
# bash api-tests/test_ucp.sh --verify-sig
# ──────────────────────────────────────────────────────────────────────────────
set -euo pipefail
MODE="${1:---infra}"
JQ="${JQ:-$(command -v jq || true)}"
# ── Base URLs ────────────────────────────────────────────────────────────────
if [ "$MODE" = "--edge" ]; then
BASE="http://localhost:8080"
CART_URL="$BASE"
ORDER_URL="$BASE"
CHECKOUT_URL="$BASE"
elif [ "$MODE" = "--order" ]; then
ORDER_URL="http://localhost:8092"
elif [ "$MODE" = "--cart" ]; then
CART_URL="http://localhost:8090"
elif [ "$MODE" = "--verify-sig" ]; then
# If signing is enabled (key mounted), you need the full stack or direct with
# env var — try both common ports.
ORDER_URL="${ORDER_URL:-http://localhost:8092}"
CART_URL="${CART_URL:-http://localhost:8090}"
else
# Default: infra stack, direct to Docker ports
CART_URL="${CART_URL:-http://localhost:8090}"
ORDER_URL="${ORDER_URL:-http://localhost:8092}"
CHECKOUT_URL="${CHECKOUT_URL:-}"
fi
echo "═══════════════════════════════════════════════════════════════"
echo " UCP API Test Suite — mode: $MODE"
echo " CART: ${CART_URL:-"(unreachable)"}"
echo " ORDER: ${ORDER_URL:-"(unreachable)"}"
echo " CHECK: ${CHECKOUT_URL:-"(unreachable)"}"
echo "═══════════════════════════════════════════════════════════════"
echo ""
# ── Helpers ──────────────────────────────────────────────────────────────────
pass() { echo "$1"; }
fail() { echo "$1"; }
header() { echo ""; echo "─── $1 ───"; }
check_jq() {
if [ -z "$JQ" ]; then
echo " ⚠️ jq not installed — showing raw JSON instead"
fi
}
maybe_jq() {
if [ -n "$JQ" ]; then
echo "$1" | jq "$2" 2>/dev/null || echo "$1" | head -c 200
else
echo "$1" | head -c 200
fi
}
assert_status() {
local label="$1" expected="$2" actual="$3" body="$4"
if [ "$actual" -eq "$expected" ]; then
pass "$label (HTTP $actual)"
else
fail "$label — expected HTTP $expected, got $actual"
echo " body: $(echo "$body" | head -c 300)"
fi
}
# ══════════════════════════════════════════════════════════════════════════════
# SECTION 1: Order service tests
# ══════════════════════════════════════════════════════════════════════════════
if [ -n "${ORDER_URL:-}" ]; then
header "1. Order Service → $ORDER_URL"
# ── Health ──────────────────────────────────────────────────────────────────
echo "--- 1.1 Health check ---"
resp=$(curl -sf "$ORDER_URL/healthz" 2>&1) && pass "/healthz" || fail "/healthz: $resp"
# ── Create order via checkout ───────────────────────────────────────────────
echo "--- 1.2 Create order via POST /checkout ---"
create_resp=$(curl -s -w '\n%{http_code}' -X POST "$ORDER_URL/checkout" \
-H 'Content-Type: application/json' \
-d '{
"orderReference": "ucp-test-1",
"currency": "SEK",
"lines": [
{
"reference": "l1",
"sku": "TEST-SKU-1",
"name": "Test Product",
"quantity": 2,
"unitPrice": 19900,
"taxRate": 2500
}
]
}')
create_code=$(echo "$create_resp" | tail -1)
create_body=$(echo "$create_resp" | sed '$d')
assert_status "Create order" 201 "$create_code" "$create_body"
ORDER_ID=$(echo "$create_body" | sed -n 's/.*"orderId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
if [ -n "$ORDER_ID" ]; then
pass "Order ID: $ORDER_ID"
else
ORDER_ID="1"
fail "Could not extract order ID, using '$ORDER_ID'"
echo " response: $(echo "$create_body" | head -c 400)"
fi
# ── Read order via UCP ──────────────────────────────────────────────────────
echo "--- 1.3 GET /ucp/v1/orders/{id} ---"
get_resp=$(curl -s -w '\n%{http_code}' "$ORDER_URL/ucp/v1/orders/$ORDER_ID")
get_code=$(echo "$get_resp" | tail -1)
get_body=$(echo "$get_resp" | sed '$d')
assert_status "GET UCP order" 200 "$get_code" "$get_body"
echo " order: $(maybe_jq "$get_body" '.data.orderId + " status=" + .data.status')"
# ── Cancel order ────────────────────────────────────────────────────────────
echo "--- 1.4 POST /ucp/v1/orders/{id}/cancel ---"
cancel_resp=$(curl -s -w '\n%{http_code}' -X POST "$ORDER_URL/ucp/v1/orders/$ORDER_ID/cancel" \
-H 'Content-Type: application/json' \
-d '{"reason": "test cancellation"}')
cancel_code=$(echo "$cancel_resp" | tail -1)
cancel_body=$(echo "$cancel_resp" | sed '$d')
# May be 200 (cancelled) or 422 (already cancelled by flow) — both ok
if [ "$cancel_code" = "200" ] || [ "$cancel_code" = "422" ]; then
pass "Cancel order (HTTP $cancel_code)"
echo " response: $(echo "$cancel_body" | head -c 200)"
if [ "$cancel_code" = "200" ]; then
ORDER_CANCELLED=true
fi
else
fail "Cancel order — expected 200 or 422, got $cancel_code"
echo " body: $(echo "$cancel_body" | head -c 300)"
fi
# ── 404 for non-existent order ──────────────────────────────────────────────
echo "--- 1.5 GET non-existent order (404) ---"
notfound_resp=$(curl -s -w '\n%{http_code}' "$ORDER_URL/ucp/v1/orders/nonexistent")
notfound_code=$(echo "$notfound_resp" | tail -1)
notfound_body=$(echo "$notfound_resp" | sed '$d')
assert_status "GET non-existent" 404 "$notfound_code" "$notfound_body"
# ── Signature headers (if enabled) ──────────────────────────────────────────
echo "--- 1.6 Check signature headers ---"
sig_resp=$(curl -s -i "$ORDER_URL/ucp/v1/orders/$ORDER_ID" 2>/dev/null | head -30)
if echo "$sig_resp" | grep -qi "signature-input"; then
pass "Signature-Input header present"
echo " $(echo "$sig_resp" | grep -i "signature-input" | head -1)"
else
echo " ️ No Signature-Input header (signing key not mounted — expected in infra stack)"
fi
if echo "$sig_resp" | grep -qi "^signature:"; then
pass "Signature header present"
else
echo " ️ No Signature header"
fi
echo ""
fi
# ══════════════════════════════════════════════════════════════════════════════
# SECTION 2: Cart service tests
# ══════════════════════════════════════════════════════════════════════════════
if [ -n "${CART_URL:-}" ]; then
header "2. Cart Service → $CART_URL"
# ── Health ──────────────────────────────────────────────────────────────────
echo "--- 2.1 Health check ---"
resp=$(curl -sf "$CART_URL/healthz" 2>&1) && pass "/healthz" || fail "/healthz: $resp"
# ── Create cart via UCP ─────────────────────────────────────────────────────
echo "--- 2.2 POST /ucp/v1/carts/ (create cart) ---"
# Note: must use trailing slash — Go's ServeMux with StripPrefix only
# registers the subtree pattern (/ucp/v1/carts/) to avoid a redirect bug.
cart_resp=$(curl -s -w '\n%{http_code}' -X POST "$CART_URL/ucp/v1/carts/" \
-H 'Content-Type: application/json' \
-d '{
"currency": "SEK",
"country": "se",
"locale": "sv-SE"
}')
cart_code=$(echo "$cart_resp" | tail -1)
cart_body=$(echo "$cart_resp" | sed '$d')
assert_status "Create cart" 201 "$cart_code" "$cart_body"
CART_ID=$(echo "$cart_body" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
if [ -n "$CART_ID" ]; then
pass "Cart ID: $CART_ID"
else
CART_ID="1"
fail "Could not extract cart ID, using '$CART_ID'"
echo " response: $(echo "$cart_body" | head -c 400)"
fi
# ── Add item to cart ────────────────────────────────────────────────────────
echo "--- 2.3 POST /ucp/v1/carts/{id}/items ---"
add_resp=$(curl -s -w '\n%{http_code}' -X POST "$CART_URL/ucp/v1/carts/$CART_ID/items" \
-H 'Content-Type: application/json' \
-d '{
"sku": "TEST-SKU-1",
"name": "Test Product",
"quantity": 1,
"unitPrice": 19900,
"taxRate": 2500
}')
add_code=$(echo "$add_resp" | tail -1)
add_body=$(echo "$add_resp" | sed '$d')
assert_status "Add item" 201 "$add_code" "$add_body"
# ── Get cart ────────────────────────────────────────────────────────────────
echo "--- 2.4 GET /ucp/v1/carts/{id} ---"
get_cart_resp=$(curl -s -w '\n%{http_code}' "$CART_URL/ucp/v1/carts/$CART_ID")
get_cart_code=$(echo "$get_cart_resp" | tail -1)
get_cart_body=$(echo "$get_cart_resp" | sed '$d')
assert_status "Get cart" 200 "$get_cart_code" "$get_cart_body"
echo " total: $(echo "$get_cart_body" | sed -n 's/.*"total"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p')"
echo ""
fi
# ══════════════════════════════════════════════════════════════════════════════
# SECTION 3: Full flow — cart → checkout → order (via edge/nginx only)
# ══════════════════════════════════════════════════════════════════════════════
if [ "$MODE" = "--edge" ] && [ -n "${CHECKOUT_URL:-}" ]; then
header "3. Full Flow (via nginx)"
# ── This section needs the full compose stack with nginx on :8080,
# and checkout must be reachable. Skip for infra/direct modes.
echo "--- 3.1 Create checkout session ---"
checkout_resp=$(curl -s -w '\n%{http_code}' -X POST "$CHECKOUT_URL/ucp/v1/checkout-sessions" \
-H 'Content-Type: application/json' \
-d '{
"cartId": "'"$CART_ID"'",
"currency": "SEK",
"country": "se"
}')
checkout_code=$(echo "$checkout_resp" | tail -1)
checkout_body=$(echo "$checkout_resp" | sed '$d')
assert_status "Create checkout session" 201 "$checkout_code" "$checkout_body"
fi
# ══════════════════════════════════════════════════════════════════════════════
# SECTION 4: Signature verification (standalone)
# ══════════════════════════════════════════════════════════════════════════════
if [ "$MODE" = "--verify-sig" ]; then
header "4. Signature Verification"
# Verify that the response includes RFC 9421 HTTP Message Signatures.
# Requires the service to have UCP_SIGNING_KEY_PATH set and the key mounted.
echo "--- 4.1 Check Signed Response ---"
sig_output=$(curl -s -D - "$ORDER_URL/ucp/v1/orders/$ORDER_ID" 2>/dev/null | head -40)
echo "$sig_output" | while IFS= read -r line; do
if echo "$line" | grep -qiE "(signature-input|signature:|x-ucp-timestamp|content-type)"; then
echo " $line"
fi
done
echo ""
echo " To manually verify:"
echo " 1. Extract the Signature-Input and Signature headers"
echo " 2. Reconstruct the signature base string per RFC 9421 §2.2"
echo " 3. Verify using the public key (x/y in docs/ucp-profile.json)"
echo ""
fi
# ══════════════════════════════════════════════════════════════════════════════
echo "═══════════════════════════════════════════════════════════════"
echo " Done."
echo ""
echo " Note: devProxies for /ucp/v1/* are now in islands.config.json."
echo " Run 'make dev' for same-origin proxied access."
echo "═══════════════════════════════════════════════════════════════"
@@ -1,4 +1,4 @@
package backofficeadmin package main
import ( import (
"bufio" "bufio"
@@ -16,9 +16,9 @@ import (
"strings" "strings"
"time" "time"
"git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/checkout" "git.k6n.net/go-cart-actor/pkg/checkout"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
) )
@@ -219,15 +219,8 @@ func (fs *FileServer) PromotionsHandler(w http.ResponseWriter, r *http.Request)
if r.Method == http.MethodGet { if r.Method == http.MethodGet {
file, err := os.Open(fileName) file, err := os.Open(fileName)
if err != nil { if err != nil {
if os.IsNotExist(err) { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
_ = os.MkdirAll(filepath.Dir(fileName), 0755) return
_ = os.WriteFile(fileName, []byte("{}"), 0644)
file, err = os.Open(fileName)
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
} }
defer file.Close() defer file.Close()
@@ -235,7 +228,6 @@ func (fs *FileServer) PromotionsHandler(w http.ResponseWriter, r *http.Request)
return return
} }
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
_ = os.MkdirAll(filepath.Dir(fileName), 0755)
file, err := os.Create(fileName) file, err := os.Create(fileName)
if err != nil { if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -255,15 +247,8 @@ func (fs *FileServer) VoucherHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet { if r.Method == http.MethodGet {
file, err := os.Open(fileName) file, err := os.Open(fileName)
if err != nil { if err != nil {
if os.IsNotExist(err) { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
_ = os.MkdirAll(filepath.Dir(fileName), 0755) return
_ = os.WriteFile(fileName, []byte("{}"), 0644)
file, err = os.Open(fileName)
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
} }
defer file.Close() defer file.Close()
@@ -271,7 +256,6 @@ func (fs *FileServer) VoucherHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
_ = os.MkdirAll(filepath.Dir(fileName), 0755)
file, err := os.Create(fileName) file, err := os.Create(fileName)
if err != nil { if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -1,4 +1,4 @@
package backofficeadmin package main
import ( import (
"math/rand" "math/rand"
@@ -7,7 +7,7 @@ import (
"testing" "testing"
"time" "time"
"git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/go-cart-actor/pkg/cart"
) )
// TestAppendFileInfoRandomProjectFile picks a random existing .go source file in the // TestAppendFileInfoRandomProjectFile picks a random existing .go source file in the
@@ -1,4 +1,4 @@
package backofficeadmin package main
import ( import (
"bufio" "bufio"
+135 -17
View File
@@ -2,16 +2,36 @@ package main
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"log" "log"
"net/http" "net/http"
"os" "os"
"time" "time"
"git.k6n.net/mats/go-cart-actor/pkg/backofficeadmin" actor "git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/go-cart-actor/pkg/checkout"
"github.com/matst80/go-redis-inventory/pkg/inventory"
"github.com/matst80/slask-finder/pkg/messaging"
amqp "github.com/rabbitmq/amqp091-go" amqp "github.com/rabbitmq/amqp091-go"
"github.com/redis/go-redis/v9"
) )
type CartFileInfo struct {
ID string `json:"id"`
CartId cart.CartId `json:"cartId"`
Size int64 `json:"size"`
Modified time.Time `json:"modified"`
}
type CheckoutFileInfo struct {
ID string `json:"id"`
CheckoutId checkout.CheckoutId `json:"checkoutId"`
Size int64 `json:"size"`
Modified time.Time `json:"modified"`
}
func envOrDefault(key, def string) string { func envOrDefault(key, def string) string {
if v := os.Getenv(key); v != "" { if v := os.Getenv(key); v != "" {
return v return v
@@ -19,22 +39,119 @@ func envOrDefault(key, def string) string {
return def 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
}
var redisAddress = os.Getenv("REDIS_ADDRESS")
var redisPassword = os.Getenv("REDIS_PASSWORD")
func main() { func main() {
dataDir := envOrDefault("DATA_DIR", "data")
addr := envOrDefault("ADDR", ":8080") addr := envOrDefault("ADDR", ":8080")
amqpURL := os.Getenv("AMQP_URL") amqpURL := os.Getenv("AMQP_URL")
app, err := backofficeadmin.New(backofficeadmin.Config{ rdb := redis.NewClient(&redis.Options{
DataDir: envOrDefault("CART_DIR", "data"), Addr: redisAddress,
CheckoutDataDir: envOrDefault("CHECKOUT_DIR", "checkout-data"), Password: redisPassword,
RedisAddress: os.Getenv("REDIS_ADDRESS"), DB: 0,
RedisPassword: os.Getenv("REDIS_PASSWORD"),
}) })
inventoryService, err := inventory.NewRedisInventoryService(rdb)
if err != nil { if err != nil {
log.Fatalf("Error creating backoffice: %v", err) log.Fatalf("Error creating inventory service: %v\n", err)
} }
_ = os.MkdirAll(dataDir, 0755)
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
diskStorage := actor.NewDiskStorage[cart.CartGrain](dataDir, reg)
checkoutDataDir := envOrDefault("CHECKOUT_DATA_DIR", "checkout-data")
_ = os.MkdirAll(checkoutDataDir, 0755)
regCheckout := checkout.NewCheckoutMutationRegistry(checkout.NewCheckoutMutationContext())
diskStorageCheckout := actor.NewDiskStorage[checkout.CheckoutGrain](checkoutDataDir, regCheckout)
fs := NewFileServer(dataDir, checkoutDataDir, diskStorage, diskStorageCheckout)
hub := NewHub()
go hub.Run()
mux := http.NewServeMux() mux := http.NewServeMux()
app.RegisterRoutes(mux) mux.HandleFunc("GET /carts", fs.CartsHandler)
mux.HandleFunc("GET /cart/{id}", fs.CartHandler)
mux.HandleFunc("GET /checkouts", fs.CheckoutsHandler)
mux.HandleFunc("GET /checkout/{id}", fs.CheckoutHandler)
mux.HandleFunc("PUT /inventory/{locationId}/{sku}", func(w http.ResponseWriter, r *http.Request) {
inventoryLocationId := inventory.LocationID(r.PathValue("locationId"))
inventorySku := inventory.SKU(r.PathValue("sku"))
pipe := rdb.Pipeline()
var payload struct {
Quantity int64 `json:"quantity"`
}
err := json.NewDecoder(r.Body).Decode(&payload)
if err != nil {
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
inventoryService.UpdateInventory(r.Context(), pipe, inventorySku, inventoryLocationId, payload.Quantity)
_, err = pipe.Exec(r.Context())
if err != nil {
http.Error(w, "failed to update inventory", http.StatusInternalServerError)
return
}
err = inventoryService.SendInventoryChanged(r.Context(), inventorySku, inventoryLocationId)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
})
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) { mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok")) _, _ = w.Write([]byte("ok"))
@@ -48,7 +165,7 @@ func main() {
_, _ = w.Write([]byte("ok")) _, _ = w.Write([]byte("ok"))
}) })
// Global CORS middleware allowing all origins and handling preflight. // Global CORS middleware allowing all origins and handling preflight
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*") 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-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
@@ -72,21 +189,22 @@ func main() {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
var conn *amqp.Connection
if amqpURL != "" { if amqpURL != "" {
conn, err = amqp.Dial(amqpURL) conn, err := amqp.Dial(amqpURL)
if err != nil { if err != nil {
log.Fatalf("failed to connect to RabbitMQ: %v", err) log.Fatalf("failed to connect to RabbitMQ: %v", err)
} }
} if err := startMutationConsumer(ctx, conn, hub); err != nil {
if err := app.Start(ctx, conn); err != nil { log.Printf("AMQP listener disabled: %v", err)
log.Printf("AMQP listener disabled: %v", err) } else {
} else if conn != nil { log.Printf("AMQP listener connected")
log.Printf("AMQP listener connected") }
} }
log.Printf("backoffice HTTP listening on %s", addr) log.Printf("backoffice HTTP listening on %s (dataDir=%s)", addr, dataDir)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("http server error: %v", err) log.Fatalf("http server error: %v", err)
} }
// server stopped
} }
+1 -1
View File
@@ -3,7 +3,7 @@ package main
import ( import (
"log" "log"
"git.k6n.net/mats/go-cart-actor/pkg/discovery" "git.k6n.net/go-cart-actor/pkg/discovery"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
+85 -204
View File
@@ -12,20 +12,16 @@ import (
"strings" "strings"
"time" "time"
"git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/go-cart-actor/pkg/cart"
cartmcp "git.k6n.net/mats/go-cart-actor/pkg/cart/mcp" "git.k6n.net/go-cart-actor/pkg/promotions"
"git.k6n.net/mats/go-cart-actor/pkg/promotions" "git.k6n.net/go-cart-actor/pkg/proxy"
promotionmcp "git.k6n.net/mats/go-cart-actor/pkg/promotions/mcp" "git.k6n.net/go-cart-actor/pkg/voucher"
"git.k6n.net/mats/go-cart-actor/internal/ucp" "github.com/matst80/go-redis-inventory/pkg/inventory"
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
"git.k6n.net/mats/slask-finder/pkg/messaging"
amqp "github.com/rabbitmq/amqp091-go"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
) )
@@ -51,43 +47,6 @@ var amqpUrl = os.Getenv("AMQP_URL")
var redisAddress = os.Getenv("REDIS_ADDRESS") var redisAddress = os.Getenv("REDIS_ADDRESS")
var redisPassword = os.Getenv("REDIS_PASSWORD") var redisPassword = os.Getenv("REDIS_PASSWORD")
// getEnv returns the value of the environment variable named by key, or def if unset/empty.
func getEnv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// normalizeListenAddr accepts either a bare port ("8080") or a full listen
// address (":8080", "0.0.0.0:8080") and returns a value usable by http.Server.Addr.
func normalizeListenAddr(v string) string {
if strings.Contains(v, ":") {
return v
}
return ":" + v
}
// loadUCPCartSigner loads the UCP ECDSA signing key from the path specified in
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
func loadUCPCartSigner() *ucp.SigningConfig {
path := os.Getenv("UCP_SIGNING_KEY_PATH")
if path == "" {
return nil
}
pemData, err := os.ReadFile(path)
if err != nil {
log.Printf("ucp signing: cannot read key %s: %v", path, err)
return nil
}
cfg, err := ucp.NewSigningConfigFromPEM(pemData, "k6n-ecdsa-2026")
if err != nil {
log.Printf("ucp signing: invalid key at %s: %v", path, err)
return nil
}
return cfg
}
func getCountryFromHost(host string) string { func getCountryFromHost(host string) string {
if strings.Contains(strings.ToLower(host), "-no") { if strings.Contains(strings.ToLower(host), "-no") {
return "no" return "no"
@@ -95,7 +54,7 @@ func getCountryFromHost(host string) string {
if strings.Contains(strings.ToLower(host), "-se") { if strings.Contains(strings.ToLower(host), "-se") {
return "se" return "se"
} }
return "se" return ""
} }
type MutationContext struct { type MutationContext struct {
@@ -124,64 +83,53 @@ func matchesSkuAndLocation(update inventory.InventoryResult, item cart.CartItem)
func main() { func main() {
// cartPort is the bare HTTP port. It drives both the local listener and the
// port used to proxy to peer pods, so a cluster must run all pods on the
// same CART_PORT.
cartPort := getEnv("CART_PORT", "8080")
if i := strings.LastIndex(cartPort, ":"); i >= 0 {
cartPort = cartPort[i+1:]
}
controlPlaneConfig := actor.DefaultServerConfig() controlPlaneConfig := actor.DefaultServerConfig()
promotionStore, err := promotions.NewStore("data/promotions.json") promotionData, err := promotions.LoadStateFile("data/promotions.json")
if err != nil { if err != nil {
log.Fatalf("Error loading promotions: %v\n", err) log.Printf("Error loading promotions: %v\n", err)
} }
log.Printf("loaded %d promotions", len(promotionStore.List())) log.Printf("loaded %d promotions", len(promotionData.State.Promotions))
promotionService := promotions.NewPromotionService(nil) inventoryPubSub := actor.NewPubSub[inventory.InventoryChange]()
promotionMCP := promotionmcp.New(promotionStore, promotionService)
//inventoryPubSub := actor.NewPubSub[inventory.InventoryChange]() // promotionService := promotions.NewPromotionService(nil)
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)
}
// rdb := redis.NewClient(&redis.Options{ inventoryReservationService, err := inventory.NewRedisCartReservationService(rdb)
// Addr: redisAddress, if err != nil {
// Password: redisPassword, log.Fatalf("Error creating inventory reservation service: %v\n", err)
// DB: 0, }
// })
// inventoryService, err := inventory.NewRedisInventoryService(rdb)
// if err != nil {
// log.Fatalf("Error creating inventory service: %v\n", err)
// }
// inventoryReservationService, err := inventory.NewRedisCartReservationService(rdb) reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(inventoryReservationService))
// if err != nil {
// log.Fatalf("Error creating inventory reservation service: %v\n", err)
// }
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
reg.RegisterProcessor( reg.RegisterProcessor(
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error { actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
_, span := tracer.Start(ctx, "Totals and promotions") _, span := tracer.Start(ctx, "Totals and promotions")
defer span.End() defer span.End()
g.UpdateTotals() g.UpdateTotals()
// Evaluate active promotions against the freshly-totalled cart and apply
// any matched actions (e.g. the Volymrabatt volume discount), which adjust
// TotalPrice/TotalDiscount on top of line items and vouchers.
promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
results, _ := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
// ApplyResults applies qualifying actions in priority order and records
// every effect — both applied discounts and pending "spend X more for ..."
// nudges with their progress — in g.AppliedPromotions.
promotionService.ApplyResults(g, results, promotionCtx)
g.Version++ g.Version++
// 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 return nil
}), }),
) )
diskStorage := actor.NewDiskStorage[cart.CartGrain](getEnv("CART_DIR", "data"), reg) diskStorage := actor.NewDiskStorage[cart.CartGrain]("data", reg)
poolConfig := actor.GrainPoolConfig[cart.CartGrain]{ poolConfig := actor.GrainPoolConfig[cart.CartGrain]{
MutationRegistry: reg, MutationRegistry: reg,
Storage: diskStorage, Storage: diskStorage,
@@ -192,46 +140,46 @@ func main() {
ret := cart.NewCartGrain(id, time.Now()) ret := cart.NewCartGrain(id, time.Now())
// Set baseline lastChange at spawn; replay may update it to last event timestamp. // Set baseline lastChange at spawn; replay may update it to last event timestamp.
// inventoryPubSub.Subscribe(ret.HandleInventoryChange) inventoryPubSub.Subscribe(ret.HandleInventoryChange)
err := diskStorage.LoadEvents(ctx, id, ret) err := diskStorage.LoadEvents(ctx, id, ret)
// if err == nil && inventoryService != nil { if err == nil && inventoryService != nil {
// refs := make([]*inventory.InventoryReference, 0) refs := make([]*inventory.InventoryReference, 0)
// for _, item := range ret.Items { for _, item := range ret.Items {
// refs = append(refs, &inventory.InventoryReference{ refs = append(refs, &inventory.InventoryReference{
// SKU: inventory.SKU(item.Sku), SKU: inventory.SKU(item.Sku),
// LocationID: getLocationId(item), LocationID: getLocationId(item),
// }) })
// } }
// _, span := tracer.Start(ctx, "update inventory") _, span := tracer.Start(ctx, "update inventory")
// defer span.End() defer span.End()
// res, err := inventoryService.GetInventoryBatch(ctx, refs...) res, err := inventoryService.GetInventoryBatch(ctx, refs...)
// if err != nil { if err != nil {
// log.Printf("unable to update inventory %v", err) log.Printf("unable to update inventory %v", err)
// } else { } else {
// for _, update := range res { for _, update := range res {
// for _, item := range ret.Items { for _, item := range ret.Items {
// if matchesSkuAndLocation(update, *item) && update.Quantity != uint32(item.Stock) { if matchesSkuAndLocation(update, *item) && update.Quantity != uint32(item.Stock) {
// // maybe apply an update to give visibility to the cart // maybe apply an update to give visibility to the cart
// item.Stock = uint16(update.Quantity) item.Stock = uint16(update.Quantity)
// } }
// } }
// } }
// } }
// } }
return ret, err return ret, err
}, },
Destroy: func(grain actor.Grain[cart.CartGrain]) error { Destroy: func(grain actor.Grain[cart.CartGrain]) error {
// cart, err := grain.GetCurrentState() cart, err := grain.GetCurrentState()
// if err != nil { if err != nil {
// return err return err
// } }
//inventoryPubSub.Unsubscribe(cart.HandleInventoryChange) inventoryPubSub.Unsubscribe(cart.HandleInventoryChange)
return nil return nil
}, },
SpawnHost: func(host string) (actor.Host[cart.CartGrain], error) { SpawnHost: func(host string) (actor.Host[cart.CartGrain], error) {
return proxy.NewRemoteHost[cart.CartGrain](host, cartPort) return proxy.NewRemoteHost[cart.CartGrain](host)
}, },
TTL: 5 * time.Minute, TTL: 5 * time.Minute,
PoolSize: 2 * 65535, PoolSize: 2 * 65535,
@@ -243,31 +191,7 @@ func main() {
log.Fatalf("Error creating cart pool: %v\n", err) log.Fatalf("Error creating cart pool: %v\n", err)
} }
cartMCP := cartmcp.New(pool) syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), inventoryService, inventoryReservationService)
// Publish each applied mutation to the "cart"/"mutation" RabbitMQ topic so the
// backoffice /commerce live feed (and other consumers) see cart activity.
// Best-effort: if AMQP is unreachable the cart still serves; the feed stays empty.
if amqpUrl != "" {
if conn, derr := amqp.Dial(amqpUrl); derr != nil {
log.Printf("cart: AMQP connect failed, mutation feed disabled: %v", derr)
} else {
if ch, cerr := conn.Channel(); cerr == nil {
_ = messaging.DefineTopic(ch, "cart", "mutation") // idempotent; non-fatal
_ = ch.Close()
}
pool.AddListener(actor.NewAmqpListener(conn, func(id uint64, results []actor.ApplyResult) (any, error) {
types := make([]string, 0, len(results))
for _, r := range results {
types = append(types, r.Type)
}
return map[string]any{"cartId": id, "mutations": types}, nil
}))
log.Printf("cart: mutation feed enabled (cart/mutation)")
}
}
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp)) //inventoryService, inventoryReservationService)
app := &App{ app := &App{
pool: pool, pool: pool,
@@ -295,43 +219,6 @@ func main() {
} }
syncedServer.Serve(mux) syncedServer.Serve(mux)
// Promotion MCP edge: list/get/upsert/status/delete promotions and preview
// discounts. Mutations persist to data/promotions.json and are picked up by
// the cart's totals processor on the next mutation (it reads a fresh snapshot).
mux.Handle("/mcp", promotionMCP.Handler())
mux.Handle("/mcp/", promotionMCP.Handler())
// Cart MCP edge: inspect and mutate carts — get_cart, get_cart_items,
// update_item_quantity, remove_cart_item, clear_cart, apply_voucher, etc.
// Exposes 11 tools on the same grain pool, mounted at /cart-mcp so it does
// not conflict with the Promotions MCP at /mcp.
mux.Handle("/cart-mcp", cartMCP.Handler())
mux.Handle("/cart-mcp/", cartMCP.Handler())
// UCP REST adapter — Universal Commerce Protocol
cartUCP := ucp.CartHandler(pool)
if signer := loadUCPCartSigner(); signer != nil {
cartUCP = ucp.WithSigning(cartUCP, signer)
log.Print("ucp signing enabled")
}
// StripPrefix is required because the sub-mux (CartHandler) registers
// routes like "GET /{id}" which expect a relative path; Go's ServeMux
// passes the full request path (e.g. /ucp/v1/carts/123) without stripping
// the prefix, so the sub-mux would never match.
//
// Only the subtree pattern (/ucp/v1/carts/) is registered, NOT the exact
// pattern (/ucp/v1/carts), because registering both causes Go's ServeMux
// to redirect the exact match to the subtree root, and StripPrefix
// interferes with the redirect target (producing a 307 to "/" instead
// of "/ucp/v1/carts/"). Consumers should use the trailing-slash variant.
mux.Handle("/ucp/v1/carts/", http.StripPrefix("/ucp/v1/carts", cartUCP))
// Stateless promotion evaluation: POST a (possibly partial) eval context and
// get back the resulting totals plus the applied/pending effect breakdown,
// without creating or mutating a real cart.
mux.HandleFunc("POST /promotions/evaluate", newPromotionEvaluateHandler(promotionStore, promotionService))
// only for local // only for local
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
pool.AddRemote(r.PathValue("host")) pool.AddRemote(r.PathValue("host"))
@@ -375,16 +262,10 @@ func main() {
mux.HandleFunc("/openapi.json", ServeEmbeddedOpenAPI) mux.HandleFunc("/openapi.json", ServeEmbeddedOpenAPI)
httpAddr := normalizeListenAddr(cartPort)
debugAddr := normalizeListenAddr(getEnv("CART_DEBUG_PORT", "8081"))
srv := &http.Server{ srv := &http.Server{
Addr: httpAddr, Addr: ":8080",
BaseContext: func(net.Listener) context.Context { return ctx }, BaseContext: func(net.Listener) context.Context { return ctx },
ReadTimeout: 10 * time.Second, ReadTimeout: 10 * time.Second,
// Close idle keep-alive connections so a load test with many short-lived
// clients doesn't pin file handles open indefinitely.
IdleTimeout: 60 * time.Second,
WriteTimeout: 20 * time.Second, WriteTimeout: 20 * time.Second,
Handler: otelhttp.NewHandler(mux, "/"), Handler: otelhttp.NewHandler(mux, "/"),
} }
@@ -403,23 +284,23 @@ func main() {
srvErr <- srv.ListenAndServe() srvErr <- srv.ListenAndServe()
}() }()
// listener := inventory.NewInventoryChangeListener(rdb, context.Background(), func(changes []inventory.InventoryChange) { listener := inventory.NewInventoryChangeListener(rdb, context.Background(), func(changes []inventory.InventoryChange) {
// for _, change := range changes { for _, change := range changes {
// log.Printf("inventory change: %v", change) log.Printf("inventory change: %v", change)
// inventoryPubSub.Publish(change) inventoryPubSub.Publish(change)
// } }
// }) })
// go func() { go func() {
// err := listener.Start() err := listener.Start()
// if err != nil { if err != nil {
// log.Fatalf("Unable to start inventory listener: %v", err) log.Fatalf("Unable to start inventory listener: %v", err)
// } }
// }() }()
log.Printf("Server started at %s (debug %s)", httpAddr, debugAddr) log.Print("Server started at port 8080")
go http.ListenAndServe(debugAddr, debugMux) go http.ListenAndServe(":8081", debugMux)
select { select {
case err = <-srvErr: case err = <-srvErr:
+4 -81
View File
@@ -358,40 +358,6 @@
} }
} }
}, },
"/cart/item/{itemId}/custom-fields": {
"put": {
"summary": "Set/merge custom input fields on a line item",
"parameters": [
{
"name": "itemId",
"in": "path",
"required": true,
"schema": { "type": "integer", "format": "int64", "minimum": 0 },
"description": "Internal cart line item identifier."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/SetCustomFieldsRequest" }
}
}
},
"responses": {
"200": {
"description": "Custom fields updated",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body or id" },
"500": { "description": "Server error" }
}
}
},
"/cart/item/{itemId}/marking": { "/cart/item/{itemId}/marking": {
"put": { "put": {
"summary": "Set marking for line item", "summary": "Set marking for line item",
@@ -989,14 +955,10 @@
}, },
"CartItem": { "CartItem": {
"type": "object", "type": "object",
"description": "Cart line item. Beyond the typed properties below, arbitrary dynamic product data is flattened onto the object as additional top-level keys (see additionalProperties).",
"properties": { "properties": {
"id": { "type": "integer" }, "id": { "type": "integer" },
"itemId": { "type": "integer" }, "itemId": { "type": "integer" },
"parentId": { "parentId": { "type": "integer" },
"type": "integer",
"description": "Line-item id (the `id` field) of the parent item, set when this line is a child/sub-article"
},
"sku": { "type": "string" }, "sku": { "type": "string" },
"price": { "$ref": "#/components/schemas/Price" }, "price": { "$ref": "#/components/schemas/Price" },
"totalPrice": { "$ref": "#/components/schemas/Price" }, "totalPrice": { "$ref": "#/components/schemas/Price" },
@@ -1013,19 +975,11 @@
"meta": { "$ref": "#/components/schemas/ItemMeta" }, "meta": { "$ref": "#/components/schemas/ItemMeta" },
"saleStatus": { "type": "string" }, "saleStatus": { "type": "string" },
"marking": { "$ref": "#/components/schemas/Marking" }, "marking": { "$ref": "#/components/schemas/Marking" },
"customFields": {
"type": "object",
"additionalProperties": { "type": "string" },
"description": "User-supplied custom input fields for this line"
},
"subscriptionDetailsId": { "type": "string" }, "subscriptionDetailsId": { "type": "string" },
"orderReference": { "type": "string" }, "orderReference": { "type": "string" },
"isSubscribed": { "type": "boolean" } "isSubscribed": { "type": "boolean" }
}, },
"required": ["id", "sku", "price", "qty"], "required": ["id", "sku", "price", "qty"]
"additionalProperties": {
"description": "Dynamic product data carried through verbatim (e.g. glas, hangning, materialkular). Value can be any JSON type."
}
}, },
"CartDelivery": { "CartDelivery": {
"type": "object", "type": "object",
@@ -1067,17 +1021,7 @@
"type": "string", "type": "string",
"description": "Two-letter country code (inferred if omitted)" "description": "Two-letter country code (inferred if omitted)"
}, },
"storeId": { "type": "string", "nullable": true }, "storeId": { "type": "string", "nullable": true }
"children": {
"type": "array",
"description": "Sub-articles (accessories, services, insurance, ...) added as separate cart lines whose parentId points to this item's line, and priced relative to this parent item.",
"items": { "$ref": "#/components/schemas/Item" }
},
"customFields": {
"type": "object",
"additionalProperties": { "type": "string" },
"description": "Optional user-supplied input fields for this line"
}
}, },
"required": ["sku"] "required": ["sku"]
}, },
@@ -1098,17 +1042,7 @@
"properties": { "properties": {
"sku": { "type": "string" }, "sku": { "type": "string" },
"quantity": { "type": "integer", "minimum": 1 }, "quantity": { "type": "integer", "minimum": 1 },
"storeId": { "type": "string", "nullable": true }, "storeId": { "type": "string", "nullable": true }
"children": {
"type": "array",
"description": "Sub-articles (accessories, services, insurance, ...). Each is added as a separate cart line whose parentId points to this item's line, and is priced relative to this parent item.",
"items": { "$ref": "#/components/schemas/Item" }
},
"customFields": {
"type": "object",
"additionalProperties": { "type": "string" },
"description": "Optional user-supplied input fields for this line"
}
}, },
"required": ["sku", "quantity"] "required": ["sku", "quantity"]
}, },
@@ -1187,17 +1121,6 @@
}, },
"required": ["type", "text"] "required": ["type", "text"]
}, },
"SetCustomFieldsRequest": {
"type": "object",
"properties": {
"customFields": {
"type": "object",
"additionalProperties": { "type": "string" },
"description": "Fields to upsert on the line; keys not present are left untouched"
}
},
"required": ["customFields"]
},
"Notice": { "Notice": {
"type": "object", "type": "object",
"properties": { "properties": {
+42 -187
View File
@@ -10,11 +10,12 @@ import (
"sync" "sync"
"time" "time"
"git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/go-cart-actor/pkg/cart"
messages "git.k6n.net/mats/go-cart-actor/proto/cart" messages "git.k6n.net/go-cart-actor/proto/cart"
"git.k6n.net/mats/go-cart-actor/pkg/voucher" "git.k6n.net/go-cart-actor/pkg/voucher"
"github.com/matst80/go-redis-inventory/pkg/inventory"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promauto"
"go.opentelemetry.io/contrib/bridges/otelslog" "go.opentelemetry.io/contrib/bridges/otelslog"
@@ -41,13 +42,17 @@ var (
type PoolServer struct { type PoolServer struct {
actor.GrainPool[cart.CartGrain] actor.GrainPool[cart.CartGrain]
pod_name string pod_name string
inventoryService inventory.InventoryService
reservationService inventory.CartReservationService
} }
func NewPoolServer(pool actor.GrainPool[cart.CartGrain], pod_name string) *PoolServer { func NewPoolServer(pool actor.GrainPool[cart.CartGrain], pod_name string, inventoryService inventory.InventoryService, inventoryReservationService inventory.CartReservationService) *PoolServer {
srv := &PoolServer{ srv := &PoolServer{
GrainPool: pool, GrainPool: pool,
pod_name: pod_name, pod_name: pod_name,
inventoryService: inventoryService,
reservationService: inventoryReservationService,
} }
return srv return srv
@@ -129,12 +134,6 @@ type Item struct {
Sku string `json:"sku"` Sku string `json:"sku"`
Quantity int `json:"quantity"` Quantity int `json:"quantity"`
StoreId *string `json:"storeId,omitempty"` StoreId *string `json:"storeId,omitempty"`
// Children are sub-articles (accessories, services, insurance, ...) priced
// relative to this item. They are added as separate cart lines whose
// ParentId points to this item's line-item id.
Children []Item `json:"children,omitempty"`
// CustomFields are optional user-supplied input fields for this line.
CustomFields map[string]string `json:"customFields,omitempty"`
} }
type SetCartItems struct { type SetCartItems struct {
@@ -142,116 +141,25 @@ type SetCartItems struct {
Items []Item `json:"items"` Items []Item `json:"items"`
} }
// itemGroup is a top-level item together with its child sub-articles. The child func getMultipleAddMessages(ctx context.Context, items []Item, country string) []proto.Message {
// AddItem messages are built with their price already derived from the parent
// product; their ParentId is filled in after the parent line is applied.
type itemGroup struct {
parent *messages.AddItem
children []*messages.AddItem
}
// buildItemGroups fetches every product and builds the AddItem messages. Groups
// are built concurrently; within a group the parent is fetched first so it can
// drive child pricing, then children are fetched concurrently. Input order is
// preserved. Items that fail to fetch are skipped (logged), matching the prior
// best-effort behavior.
func buildItemGroups(ctx context.Context, items []Item, country string) []itemGroup {
groups := make([]itemGroup, len(items))
wg := sync.WaitGroup{} wg := sync.WaitGroup{}
for i, itm := range items { mu := sync.Mutex{}
wg.Go(func() { msgs := make([]proto.Message, 0, len(items))
parentMsg, parentProduct, err := BuildItemMessage(ctx, itm.Sku, itm.Quantity, country, itm.StoreId, nil) for _, itm := range items {
if err != nil { wg.Go(
log.Printf("error adding item %s: %v", itm.Sku, err) func() {
return msg, err := GetItemAddMessage(ctx, itm.Sku, itm.Quantity, country, itm.StoreId)
} if err != nil {
parentMsg.CustomFields = itm.CustomFields log.Printf("error adding item %s: %v", itm.Sku, err)
groups[i].parent = parentMsg return
if len(itm.Children) == 0 {
return
}
children := make([]*messages.AddItem, len(itm.Children))
cwg := sync.WaitGroup{}
for j, child := range itm.Children {
cwg.Go(func() {
childMsg, _, err := BuildItemMessage(ctx, child.Sku, child.Quantity, country, child.StoreId, parentProduct)
if err != nil {
log.Printf("error adding child %s of %s: %v", child.Sku, itm.Sku, err)
return
}
childMsg.CustomFields = child.CustomFields
children[j] = childMsg
})
}
cwg.Wait()
for _, c := range children {
if c != nil {
groups[i].children = append(groups[i].children, c)
} }
} mu.Lock()
}) msgs = append(msgs, msg)
mu.Unlock()
})
} }
wg.Wait() wg.Wait()
return groups return msgs
}
// applyItemGroups applies each group in dependency order: the parent first, then
// its children with ParentId set to the parent's resolved line-item id. Returns
// the last mutation result for rendering.
func (s *PoolServer) applyItemGroups(ctx context.Context, id cart.CartId, groups []itemGroup) (*actor.MutationResult[cart.CartGrain], error) {
var last *actor.MutationResult[cart.CartGrain]
for _, g := range groups {
if g.parent == nil {
continue
}
res, err := s.ApplyLocal(ctx, id, g.parent)
if err != nil {
return nil, err
}
last = res
if len(g.children) == 0 {
continue
}
parentLineId, ok := parentLineId(&res.Result, g.parent)
if !ok {
log.Printf("could not resolve parent line for item %d (sku %s); skipping %d children",
g.parent.ItemId, g.parent.Sku, len(g.children))
continue
}
childMsgs := make([]proto.Message, len(g.children))
for i, c := range g.children {
c.ParentId = &parentLineId
childMsgs[i] = c
}
res, err = s.ApplyLocal(ctx, id, childMsgs...)
if err != nil {
return nil, err
}
last = res
}
return last, nil
}
// parentLineId finds the line-item id of the just-applied parent: the resident
// item with the parent's catalog ItemId, matching store, and no parent of its
// own. AddItem merges by sku+store, so at most one such line exists.
func parentLineId(grain *cart.CartGrain, parent *messages.AddItem) (uint32, bool) {
for _, it := range grain.Items {
if it == nil || it.ParentId != nil {
continue
}
if it.ItemId != parent.ItemId {
continue
}
sameStore := (it.StoreId == nil && parent.StoreId == nil) ||
(it.StoreId != nil && parent.StoreId != nil && *it.StoreId == *parent.StoreId)
if sameStore {
return it.Id, true
}
}
return 0, false
} }
func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error { func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
@@ -261,22 +169,14 @@ func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request,
return err return err
} }
if _, err := s.ApplyLocal(r.Context(), id, &messages.ClearCartRequest{}); err != nil { msgs := make([]proto.Message, 0, len(setCartItems.Items)+1)
return err msgs = append(msgs, &messages.ClearCartRequest{})
} msgs = append(msgs, getMultipleAddMessages(r.Context(), setCartItems.Items, setCartItems.Country)...)
groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country)
reply, err := s.applyItemGroups(r.Context(), id, groups) reply, err := s.ApplyLocal(r.Context(), id, msgs...)
if err != nil { if err != nil {
return err return err
} }
if reply == nil {
// Cart was cleared but nothing added; return current (empty) state.
grain, err := s.Get(r.Context(), uint64(id))
if err != nil {
return err
}
return s.WriteResult(w, grain)
}
return s.WriteResult(w, reply) return s.WriteResult(w, reply)
} }
@@ -287,18 +187,12 @@ func (s *PoolServer) AddMultipleItemHandler(w http.ResponseWriter, r *http.Reque
return err return err
} }
groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country) msgs := getMultipleAddMessages(r.Context(), setCartItems.Items, setCartItems.Country)
reply, err := s.applyItemGroups(r.Context(), id, groups)
reply, err := s.ApplyLocal(r.Context(), id, msgs...)
if err != nil { if err != nil {
return err return err
} }
if reply == nil {
grain, err := s.Get(r.Context(), uint64(id))
if err != nil {
return err
}
return s.WriteResult(w, grain)
}
return s.WriteResult(w, reply) return s.WriteResult(w, reply)
} }
@@ -307,10 +201,6 @@ type AddRequest struct {
Quantity int32 `json:"quantity"` Quantity int32 `json:"quantity"`
Country string `json:"country"` Country string `json:"country"`
StoreId *string `json:"storeId"` StoreId *string `json:"storeId"`
// Children are sub-articles priced relative to this item (see Item.Children).
Children []Item `json:"children,omitempty"`
// CustomFields are optional user-supplied input fields for this line.
CustomFields map[string]string `json:"customFields,omitempty"`
} }
func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error { func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
@@ -319,26 +209,16 @@ func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request
if err != nil { if err != nil {
return err return err
} }
msg, err := GetItemAddMessage(r.Context(), addRequest.Sku, int(addRequest.Quantity), addRequest.Country, addRequest.StoreId)
item := Item{
Sku: addRequest.Sku,
Quantity: int(addRequest.Quantity),
StoreId: addRequest.StoreId,
Children: addRequest.Children,
CustomFields: addRequest.CustomFields,
}
groups := buildItemGroups(r.Context(), []Item{item}, addRequest.Country)
reply, err := s.applyItemGroups(r.Context(), id, groups)
if err != nil { if err != nil {
return err return err
} }
if reply == nil {
grain, err := s.Get(r.Context(), uint64(id)) reply, err := s.ApplyLocal(r.Context(), id, msg)
if err != nil { if err != nil {
return err return err
}
return s.WriteResult(w, grain)
} }
return s.WriteResult(w, reply) return s.WriteResult(w, reply)
} }
@@ -556,29 +436,6 @@ func (s *PoolServer) RemoveLineItemMarkingHandler(w http.ResponseWriter, r *http
return s.WriteResult(w, reply) return s.WriteResult(w, reply)
} }
type SetCustomFieldsRequest struct {
CustomFields map[string]string `json:"customFields"`
}
func (s *PoolServer) SetCustomFieldsHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
itemId, err := strconv.ParseInt(r.PathValue("itemId"), 10, 64)
if err != nil {
return err
}
req := SetCustomFieldsRequest{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return err
}
reply, err := s.ApplyLocal(r.Context(), cartId, &messages.SetLineItemCustomFields{
Id: uint32(itemId),
CustomFields: req.CustomFields,
})
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) InternalApplyMutationHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error { func (s *PoolServer) InternalApplyMutationHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)
@@ -656,7 +513,6 @@ func (s *PoolServer) Serve(mux *http.ServeMux) {
handleFunc("PUT /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler))) handleFunc("PUT /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
handleFunc("DELETE /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler))) handleFunc("DELETE /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
handleFunc("PUT /cart/item/{itemId}/custom-fields", CookieCartIdHandler(s.ProxyHandler(s.SetCustomFieldsHandler)))
//mux.HandleFunc("GET /cart/checkout", CookieCartIdHandler(s.ProxyHandler(s.HandleCheckout))) //mux.HandleFunc("GET /cart/checkout", CookieCartIdHandler(s.ProxyHandler(s.HandleCheckout)))
//mux.HandleFunc("GET /cart/confirmation/{orderId}", CookieCartIdHandler(s.ProxyHandler(s.HandleConfirmation))) //mux.HandleFunc("GET /cart/confirmation/{orderId}", CookieCartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
@@ -671,5 +527,4 @@ func (s *PoolServer) Serve(mux *http.ServeMux) {
handleFunc("PUT /cart/byid/{id}/user", CartIdHandler(s.ProxyHandler(s.SetUserIdHandler))) handleFunc("PUT /cart/byid/{id}/user", CartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
handleFunc("PUT /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler))) handleFunc("PUT /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
handleFunc("DELETE /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler))) handleFunc("DELETE /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
handleFunc("PUT /cart/byid/{id}/item/{itemId}/custom-fields", CartIdHandler(s.ProxyHandler(s.SetCustomFieldsHandler)))
} }
+111 -180
View File
@@ -4,77 +4,34 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"math"
"net/http" "net/http"
"strconv"
messages "git.k6n.net/mats/go-cart-actor/proto/cart" "git.k6n.net/go-cart-actor/pkg/cart"
messages "git.k6n.net/go-cart-actor/proto/cart"
"github.com/matst80/slask-finder/pkg/index"
) )
// consumedKeys are product-document keys that are mapped to typed AddItem // TODO make this configurable
// fields. They are stripped from the dynamic remainder so Extra only holds
// genuinely dynamic data.
var consumedKeys = []string{
"id", "sku", "title", "img", "price", "vat",
"discount", "inStock", "supplierId", "supplierName", "deleted",
}
// getBaseUrl returns the product service base url. Overridable via
// PRODUCT_BASE_URL (the country argument is currently unused but kept for when
// per-market routing returns).
func getBaseUrl(country string) string { func getBaseUrl(country string) string {
return getEnv("PRODUCT_BASE_URL", "http://localhost:8082") // if country == "se" {
// return "http://s10n-se:8080"
// }
if country == "no" {
return "http://s10n-no.s10n:8080"
}
if country == "se" {
return "http://s10n-se.s10n:8080"
}
return "http://localhost:8082"
} }
// ProductItem is the flat product document served by GET /api/get/{sku}. func FetchItem(ctx context.Context, sku string, country string) (*index.DataItem, error) {
// Only the fields the cart currently maps mechanically are decoded; the rest
// of the (dynamic) document is intentionally ignored until the cart item model
// is reworked to carry arbitrary data.
type ProductItem struct {
Id uint64 `json:"id"`
Sku string `json:"sku"`
Title string `json:"title"`
Img string `json:"img"`
Price float64 `json:"price"`
Vat int `json:"vat"`
Discount int `json:"discount"` // percent off the original price
InStock int32 `json:"inStock"`
SupplierId int `json:"supplierId"`
SupplierName string `json:"supplierName"`
Deleted bool `json:"deleted"`
// Extra is the rest of the product document (everything not mapped to a
// typed field above), preserved verbatim and surfaced on the cart item.
Extra map[string]json.RawMessage `json:"-"`
}
// numberField reads a numeric dynamic property from the product document
// (width, height, ...). Like JS Number(), it accepts a JSON number or a numeric
// string. Returns false when the key is absent or not numeric.
func (p *ProductItem) numberField(key string) (float64, bool) {
raw, ok := p.Extra[key]
if !ok {
return 0, false
}
var f float64
if err := json.Unmarshal(raw, &f); err == nil {
return f, true
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
if f, err := strconv.ParseFloat(s, 64); err == nil {
return f, true
}
}
return 0, false
}
func FetchItem(ctx context.Context, sku string, country string) (*ProductItem, error) {
baseUrl := getBaseUrl(country) baseUrl := getBaseUrl(country)
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)) innerCtx, span := tracer.Start(ctx, fmt.Sprintf("fetching data for %s", sku))
defer span.End() defer span.End()
req, err := http.NewRequestWithContext(innerCtx, http.MethodGet, fmt.Sprintf("%s/api/get/%s", baseUrl, sku), nil) req = req.WithContext(innerCtx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -83,137 +40,111 @@ func FetchItem(ctx context.Context, sku string, country string) (*ProductItem, e
return nil, err return nil, err
} }
defer res.Body.Close() defer res.Body.Close()
if res.StatusCode != http.StatusOK { var item index.DataItem
return nil, fmt.Errorf("product service returned %d for sku %s", res.StatusCode, sku) err = json.NewDecoder(res.Body).Decode(&item)
} return &item, err
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
var item ProductItem
if err := json.Unmarshal(body, &item); err != nil {
return nil, err
}
// Capture everything not mapped to a typed field as dynamic data.
all := map[string]json.RawMessage{}
if err := json.Unmarshal(body, &all); err != nil {
return nil, err
}
for _, k := range consumedKeys {
delete(all, k)
}
if len(all) > 0 {
item.Extra = all
}
return &item, nil
} }
func GetItemAddMessage(ctx context.Context, sku string, qty int, country string, storeId *string) (*messages.AddItem, error) { func GetItemAddMessage(ctx context.Context, sku string, qty int, country string, storeId *string) (*messages.AddItem, error) {
msg, _, err := BuildItemMessage(ctx, sku, qty, country, storeId, nil)
return msg, err
}
// BuildItemMessage fetches a product and builds its AddItem mutation, returning
// the fetched product too so it can serve as the parent for child items. When
// parent is non-nil the line is priced as a child of that parent.
func BuildItemMessage(ctx context.Context, sku string, qty int, country string, storeId *string, parent *ProductItem) (*messages.AddItem, *ProductItem, error) {
item, err := FetchItem(ctx, sku, country) item, err := FetchItem(ctx, sku, country)
if err != nil { if err != nil {
return nil, nil, err return nil, err
} }
msg, err := ToItemAddMessage(item, parent, storeId, qty, country) return ToItemAddMessage(item, storeId, qty, country)
}
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 { if err != nil {
return nil, nil, err return nil, err
} }
return msg, item, nil stk := item.GetStock()
} stock := cart.StockStatus(0)
// Glass is billed at a minimum surface even when the real pane is smaller, so a
// small window doesn't get a near-zero per-m² price.
const minGlassArea = 0.4 // m²
// areaFromItem derives the main article's *visible glass* surface in m² from a
// resolved configurator selection. The catalog width/height are facet codes;
// the outer (karmytter) size is `code × 100 margin` mm and the visible glass
// is that minus the frame border per axis, e.g. width 4, widthMargin 20,
// borderWidth 194 → 400 20 194 = 186 mm. Area = glassW(mm) × glassH(mm) /
// 1e6, clamped up to the minGlassArea billing floor. Returns 0 when a dimension
// is missing/non-numeric (non-window PDP, or no product resolved yet).
func areaFromItem(item *ProductItem) float64 {
if item == nil {
return 0
}
w, okW := item.numberField("width")
h, okH := item.numberField("height")
if !okW || !okH || w <= 0 || h <= 0 {
return 0
}
glassW := w * 100 // - (item.numberField("widthMargin") + item.numberField("borderWidth"))
glassH := h * 100 // - (item.numberField("heightMargin") + item.numberField("borderHeight"))
if glassW <= 0 || glassH <= 0 {
return 0
}
return math.Max((glassW*glassH)/1_000_000, minGlassArea)
}
// accessoryPrice computes a child line's unit price (inc vat, minor units): the
// child's own per-m² price scaled by the parent window's billed glass area.
// When the parent has no usable dimensions the area is 0, so the price is 0.
func accessoryPrice(parent *ProductItem, child *ProductItem) int64 {
area := areaFromItem(parent)
return int64(child.Price*100*area + 0.5)
}
// orgPriceFromDiscount reconstructs the pre-discount price from a percentage.
// Returns 0 when there is no usable discount, in which case OrgPrice is omitted.
func orgPriceFromDiscount(price int64, discountPercent int) int64 {
if discountPercent <= 0 || discountPercent >= 100 {
return 0
}
return int64(float64(price)/(1-float64(discountPercent)/100) + 0.5)
}
func ToItemAddMessage(item *ProductItem, parent *ProductItem, storeId *string, qty int, country string) (*messages.AddItem, error) {
// Central stock only: store-specific stock is no longer in the payload, so a
// storeId request currently resolves to zero stock.
var stock int32
if storeId == nil { if storeId == nil {
stock = item.InStock centralStock, ok := stk[country]
} if ok {
if !item.Buyable {
return nil, fmt.Errorf("item not available")
}
// Top-level items price from their own product; children are priced from the if centralStock == 0 && item.SaleStatus == "TBD" {
// parent product via the accessory-price hook. return nil, fmt.Errorf("no items available")
price := int64(item.Price) }
if parent != nil { stock = cart.StockStatus(centralStock)
price = accessoryPrice(parent, item)
}
msg := &messages.AddItem{
ItemId: uint32(item.Id),
Quantity: int32(qty),
Price: price,
OrgPrice: orgPriceFromDiscount(price, item.Discount),
Sku: item.Sku,
Name: item.Title,
Image: item.Img,
Stock: stock,
Tax: int32(item.Vat * 100),
SellerId: strconv.Itoa(item.SupplierId),
SellerName: item.SupplierName,
Country: country,
StoreId: storeId,
}
if len(item.Extra) > 0 {
extra, err := json.Marshal(item.Extra)
if err != nil {
return nil, fmt.Errorf("marshal extra product data: %w", err)
} }
msg.ExtraJson = extra
} else {
if !item.BuyableInStore {
return nil, fmt.Errorf("item not available in store")
}
storeStock, ok := stk[*storeId]
if ok {
stock = cart.StockStatus(storeStock)
}
} }
return msg, nil articleType, _ := item.GetStringFieldValue(1) //.Fields[1].(string)
outletGrade, ok := item.GetStringFieldValue(20) //.Fields[20].(string)
var outlet *string
if ok {
outlet = &outletGrade
}
sellerId, _ := item.GetStringFieldValue(24) // .Fields[24].(string)
sellerName, _ := item.GetStringFieldValue(9) // .Fields[9].(string)
brand, _ := item.GetStringFieldValue(2) //.Fields[2].(string)
category, _ := item.GetStringFieldValue(10) //.Fields[10].(string)
category2, _ := item.GetStringFieldValue(11) //.Fields[11].(string)
category3, _ := item.GetStringFieldValue(12) //.Fields[12].(string)
category4, _ := item.GetStringFieldValue(13) //Fields[13].(string)
category5, _ := item.GetStringFieldValue(14) //.Fields[14].(string)
cgm, _ := item.GetStringFieldValue(35) // Customer Group Membership
return &messages.AddItem{
ItemId: uint32(item.Id),
Quantity: int32(qty),
Price: int64(price),
OrgPrice: int64(orgPrice),
Sku: item.GetSku(),
Name: item.Title,
Image: item.Img,
Stock: int32(stock),
Brand: brand,
Category: category,
Category2: category2,
Category3: category3,
Category4: category4,
Category5: category5,
Tax: getTax(articleType),
SellerId: sellerId,
SellerName: sellerName,
ArticleType: articleType,
Disclaimer: item.Disclaimer,
Country: country,
Outlet: outlet,
StoreId: storeId,
SaleStatus: item.SaleStatus,
Cgm: cgm,
}, nil
}
func getTax(articleType string) int32 {
switch articleType {
case "ZDIE":
return 600
default:
return 2500
}
}
func getInt(data float64, ok bool) (int, error) {
if !ok {
return 0, fmt.Errorf("invalid type")
}
return int(data), nil
} }
@@ -1,390 +0,0 @@
//go:build integration
// Integration tests for the product fetcher. These hit a live product service
// and are excluded from the default build/test run. Run them with:
//
// go test -tags=integration ./cmd/cart/
//
// Point at a different service with PRODUCT_BASE_URL (default http://localhost:8082).
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"net"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
)
// newTestPoolServer builds a real, disk-backed PoolServer for exercising the
// HTTP handlers end-to-end (no clustering peers).
func newTestPoolServer(t *testing.T) *PoolServer {
t.Helper()
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
reg.RegisterProcessor(actor.NewMutationProcessor(func(_ context.Context, g *cart.CartGrain) error {
g.UpdateTotals()
g.Version++
return nil
}))
dir := t.TempDir()
disk := actor.NewDiskStorage[cart.CartGrain](dir, reg)
pool, err := actor.NewSimpleGrainPool(actor.GrainPoolConfig[cart.CartGrain]{
MutationRegistry: reg,
Storage: disk,
Spawn: func(ctx context.Context, id uint64) (actor.Grain[cart.CartGrain], error) {
g := cart.NewCartGrain(id, time.Now())
err := disk.LoadEvents(ctx, id, g)
return g, err
},
Destroy: func(actor.Grain[cart.CartGrain]) error { return nil },
SpawnHost: func(host string) (actor.Host[cart.CartGrain], error) {
return proxy.NewRemoteHost[cart.CartGrain](host, "8080")
},
TTL: 5 * time.Minute,
PoolSize: 1000,
Hostname: "",
})
if err != nil {
t.Fatalf("new pool: %v", err)
}
return NewPoolServer(pool, "test")
}
// exampleId is the catalog item the fetcher rework was validated against.
const exampleId = "8163"
// requireService skips the test when the product service is not reachable so
// the suite degrades gracefully in environments without it.
func requireService(t *testing.T) {
t.Helper()
base := getBaseUrl("se")
req, err := http.NewRequest(http.MethodGet, base+"/api/get/"+exampleId, nil)
if err != nil {
t.Fatalf("build probe request: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
res, err := http.DefaultClient.Do(req.WithContext(ctx))
if err != nil {
var netErr net.Error
if errors.As(err, &netErr) || errors.Is(err, context.DeadlineExceeded) {
t.Skipf("product service %s not reachable: %v", base, err)
}
t.Fatalf("probe product service: %v", err)
}
res.Body.Close()
}
func TestFetchItem_Example8163(t *testing.T) {
requireService(t)
ctx := context.Background()
item, err := FetchItem(ctx, exampleId, "se")
if err != nil {
t.Fatalf("FetchItem(%s): %v", exampleId, err)
}
if item.Id != 8163 {
t.Errorf("Id = %d, want 8163", item.Id)
}
if item.Sku == "" {
t.Error("Sku is empty")
}
if item.Price <= 0 {
t.Errorf("Price = %v, want > 0", item.Price)
}
t.Logf("fetched %d sku=%s price=%v vat=%d discount=%d inStock=%d supplier=%q",
item.Id, item.Sku, item.Price, item.Vat, item.Discount, item.InStock, item.SupplierName)
}
func TestToItemAddMessage_Example8163(t *testing.T) {
requireService(t)
ctx := context.Background()
item, err := FetchItem(ctx, exampleId, "se")
if err != nil {
t.Fatalf("FetchItem(%s): %v", exampleId, err)
}
// Central stock (no storeId, no parent).
msg, err := ToItemAddMessage(item, nil, nil, 2, "se")
if err != nil {
t.Fatalf("ToItemAddMessage: %v", err)
}
if msg.ItemId != uint32(item.Id) {
t.Errorf("ItemId = %d, want %d", msg.ItemId, item.Id)
}
if msg.Quantity != 2 {
t.Errorf("Quantity = %d, want 2", msg.Quantity)
}
if want := int64(item.Price * 100); msg.Price != want {
t.Errorf("Price = %d, want %d (price*100)", msg.Price, want)
}
if msg.Sku != item.Sku {
t.Errorf("Sku = %q, want %q", msg.Sku, item.Sku)
}
if msg.Name == "" {
t.Error("Name is empty")
}
if want := int32(item.Vat * 100); msg.Tax != want {
t.Errorf("Tax = %d, want vat*100 = %d", msg.Tax, want)
}
if msg.Stock != item.InStock {
t.Errorf("Stock = %d, want central inStock %d", msg.Stock, item.InStock)
}
if msg.SellerName != item.SupplierName {
t.Errorf("SellerName = %q, want %q", msg.SellerName, item.SupplierName)
}
if want := strconv.Itoa(item.SupplierId); msg.SellerId != want {
t.Errorf("SellerId = %q, want %q", msg.SellerId, want)
}
// OrgPrice is reconstructed from the discount percent.
if item.Discount > 0 && item.Discount < 100 {
if msg.OrgPrice <= msg.Price {
t.Errorf("OrgPrice = %d, want > Price %d (discount %d%%)", msg.OrgPrice, msg.Price, item.Discount)
}
} else if msg.OrgPrice != 0 {
t.Errorf("OrgPrice = %d, want 0 (no usable discount)", msg.OrgPrice)
}
// Store stock currently resolves to zero (store-level stock not in payload).
storeId := "1234"
storeMsg, err := ToItemAddMessage(item, nil, &storeId, 1, "se")
if err != nil {
t.Fatalf("ToItemAddMessage(store): %v", err)
}
if storeMsg.Stock != 0 {
t.Errorf("store Stock = %d, want 0", storeMsg.Stock)
}
if storeMsg.StoreId == nil || *storeMsg.StoreId != storeId {
t.Errorf("StoreId = %v, want %q", storeMsg.StoreId, storeId)
}
if len(msg.ExtraJson) == 0 {
t.Error("expected ExtraJson to carry dynamic product data")
}
}
// TestDynamicData_RoundTripsToCartItem exercises the full path: fetch the
// product, build the AddItem mutation, apply it through the cart mutation
// registry, then render the cart exactly as the API does — asserting that a
// dynamic product key ("glas" for item 8163) is flattened onto the item.
func TestDynamicData_RoundTripsToCartItem(t *testing.T) {
requireService(t)
ctx := context.Background()
msg, err := GetItemAddMessage(ctx, exampleId, 1, "se", nil)
if err != nil {
t.Fatalf("GetItemAddMessage: %v", err)
}
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
grain := cart.NewCartGrain(1, time.Now())
if _, err := reg.Apply(ctx, grain, msg); err != nil {
t.Fatalf("apply AddItem: %v", err)
}
// Marshal the grain the way the HTTP handlers do (WriteResult -> json).
b, err := json.Marshal(grain)
if err != nil {
t.Fatalf("marshal grain: %v", err)
}
var rendered struct {
Items []map[string]json.RawMessage `json:"items"`
}
if err := json.Unmarshal(b, &rendered); err != nil {
t.Fatalf("unmarshal grain: %v", err)
}
if len(rendered.Items) != 1 {
t.Fatalf("items = %d, want 1", len(rendered.Items))
}
item := rendered.Items[0]
// Typed field present.
if _, ok := item["sku"]; !ok {
t.Error("rendered item missing typed key \"sku\"")
}
// Dynamic key flattened onto the item and returned over the API.
glas, ok := item["glas"]
if !ok {
t.Fatalf("rendered item missing dynamic key \"glas\"; got keys %v", keysOf(item))
}
if string(glas) != `"V"` {
t.Errorf("glas = %s, want \"V\"", glas)
}
}
// TestSetCartItems_ParentWithChildren reproduces the reported scenario through
// the real SetCartItemsHandler: a parent SKU with three distinct children.
func TestSetCartItems_ParentWithChildren(t *testing.T) {
requireService(t)
s := newTestPoolServer(t)
body := `{"sku":"146620","quantity":1,"children":[{"sku":"170852","quantity":1},{"sku":"123075","quantity":1},{"sku":"123076","quantity":1}]}`
// The handler decodes SetCartItems{country, items:[Item]} — the reported
// payload is a single Item, so wrap it in the items array.
payload := `{"country":"se","items":[` + body + `]}`
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/cart/set", bytes.NewBufferString(payload))
if err := s.SetCartItemsHandler(rec, req, cart.CartId(42)); err != nil {
t.Fatalf("SetCartItemsHandler: %v", err)
}
type lineT struct {
Id uint32 `json:"id"`
ItemId uint32 `json:"itemId"`
ParentId *uint32 `json:"parentId"`
Sku string `json:"sku"`
}
// Handler returns a MutationResult {result:{items}, mutations}. Fall back to
// top-level items for the empty-cart path.
var resp struct {
Result struct {
Items []lineT `json:"items"`
} `json:"result"`
Items []lineT `json:"items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v\nbody: %s", err, rec.Body.String())
}
grain := struct{ Items []lineT }{Items: resp.Result.Items}
if len(grain.Items) == 0 {
grain.Items = resp.Items
}
t.Logf("result has %d items:", len(grain.Items))
for _, it := range grain.Items {
t.Logf(" line %d itemId=%d sku=%s parentId=%v", it.Id, it.ItemId, it.Sku, it.ParentId)
}
if len(grain.Items) != 4 {
t.Fatalf("items = %d, want 4 (parent + 3 children)", len(grain.Items))
}
children := 0
for _, it := range grain.Items {
if it.ParentId != nil {
children++
}
}
if children != 3 {
t.Errorf("children = %d, want 3", children)
}
}
// TestAddSkuRequest_SingleItemWithChildren posts the bare single-item body (no
// items wrapper) to the single-add handler and asserts children are added.
func TestAddSkuRequest_SingleItemWithChildren(t *testing.T) {
requireService(t)
s := newTestPoolServer(t)
payload := `{"sku":"146620","quantity":1,"country":"se","children":[{"sku":"170852","quantity":1},{"sku":"123075","quantity":1},{"sku":"123076","quantity":1}]}`
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/cart", bytes.NewBufferString(payload))
if err := s.AddSkuRequestHandler(rec, req, cart.CartId(7)); err != nil {
t.Fatalf("AddSkuRequestHandler: %v", err)
}
var resp struct {
Result struct {
Items []struct {
ParentId *uint32 `json:"parentId"`
} `json:"items"`
} `json:"result"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v\nbody: %s", err, rec.Body.String())
}
if len(resp.Result.Items) != 4 {
t.Fatalf("items = %d, want 4 (parent + 3 children)", len(resp.Result.Items))
}
}
func keysOf(m map[string]json.RawMessage) []string {
ks := make([]string, 0, len(m))
for k := range m {
ks = append(ks, k)
}
return ks
}
// TestChildren_ParentLinkage exercises the nested-children build + ordered apply:
// buildItemGroups fetches the parent product and prices the child against it,
// then parentLineId resolves the parent's line id and the child line links to it.
//
// Structural test: it reuses 8163 as both parent and child (child pinned to a
// store so it does not merge with the parent line). Real children would be
// distinct service/insurance/accessory SKUs.
func TestChildren_ParentLinkage(t *testing.T) {
requireService(t)
ctx := context.Background()
childStore := "1"
items := []Item{{
Sku: exampleId,
Quantity: 1,
Children: []Item{{Sku: exampleId, Quantity: 1, StoreId: &childStore}},
}}
groups := buildItemGroups(ctx, items, "se")
if len(groups) != 1 {
t.Fatalf("groups = %d, want 1", len(groups))
}
if groups[0].parent == nil {
t.Fatal("parent message not built")
}
if len(groups[0].children) != 1 {
t.Fatalf("children = %d, want 1", len(groups[0].children))
}
// Apply through the real mutation registry to verify the linkage the handler
// performs (applyItemGroups does the same with pool.ApplyLocal).
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
grain := cart.NewCartGrain(1, time.Now())
if _, err := reg.Apply(ctx, grain, groups[0].parent); err != nil {
t.Fatalf("apply parent: %v", err)
}
pid, ok := parentLineId(grain, groups[0].parent)
if !ok {
t.Fatal("parentLineId did not resolve the parent line")
}
child := groups[0].children[0]
child.ParentId = &pid
if _, err := reg.Apply(ctx, grain, child); err != nil {
t.Fatalf("apply child: %v", err)
}
if len(grain.Items) != 2 {
t.Fatalf("cart items = %d, want 2 (parent + child)", len(grain.Items))
}
var parentLine, childLine *cart.CartItem
for _, it := range grain.Items {
if it.ParentId == nil {
parentLine = it
} else {
childLine = it
}
}
if parentLine == nil || childLine == nil {
t.Fatalf("expected one parent and one child line, got %+v", grain.Items)
}
if *childLine.ParentId != parentLine.Id {
t.Errorf("child.ParentId = %d, want parent line id %d", *childLine.ParentId, parentLine.Id)
}
t.Logf("parent line %d, child line %d -> parent %d", parentLine.Id, childLine.Id, *childLine.ParentId)
}
-60
View File
@@ -1,60 +0,0 @@
package main
import (
"encoding/json"
"testing"
)
func makeProduct(price float64, extra map[string]any) *ProductItem {
m := map[string]json.RawMessage{}
for k, v := range extra {
b, _ := json.Marshal(v)
m[k] = b
}
return &ProductItem{Price: price, Extra: m}
}
func TestAreaFromItem(t *testing.T) {
tests := []struct {
name string
extra map[string]any
want float64
}{
{"floors small window to min", map[string]any{"width": 5, "height": 6}, 0.4}, // 500*600/1e6 = 0.30 -> floor 0.4
{"normal window", map[string]any{"width": 10, "height": 10}, 1.0}, // 1000*1000/1e6 = 1.0
{"numeric strings", map[string]any{"width": "10", "height": "10"}, 1.0}, // JS Number() parity
{"missing height", map[string]any{"width": 10}, 0},
{"zero width", map[string]any{"width": 0, "height": 10}, 0},
{"non-numeric", map[string]any{"width": "abc", "height": 10}, 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := areaFromItem(makeProduct(0, tc.extra))
if got != tc.want {
t.Errorf("areaFromItem = %v, want %v", got, tc.want)
}
})
}
if got := areaFromItem(nil); got != 0 {
t.Errorf("areaFromItem(nil) = %v, want 0", got)
}
}
func TestAccessoryPrice(t *testing.T) {
// area 1.0 m^2, child 100.00 -> 100.00 * 100 * 1.0 = 10000 minor units.
parent := makeProduct(0, map[string]any{"width": 10, "height": 10})
if got := accessoryPrice(parent, makeProduct(100, nil)); got != 10000 {
t.Errorf("accessoryPrice = %d, want 10000", got)
}
// area floored to 0.4, child 9952.78 -> round(9952.78 * 100 * 0.4) = 398111.
small := makeProduct(0, map[string]any{"width": 5, "height": 6})
if got := accessoryPrice(small, makeProduct(9952.78, nil)); got != 398111 {
t.Errorf("accessoryPrice = %d, want 398111", got)
}
// parent without dimensions -> area 0 -> price 0.
if got := accessoryPrice(makeProduct(0, nil), makeProduct(500, nil)); got != 0 {
t.Errorf("accessoryPrice (no dims) = %d, want 0", got)
}
}
-29
View File
@@ -1,29 +0,0 @@
package main
import (
"encoding/json"
"errors"
"io"
"net/http"
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
)
// newPromotionEvaluateHandler serves POST /promotions/evaluate: it takes a
// (possibly partial) evaluation context and returns the totals plus the
// applied/pending promotion effects, without creating or mutating a real cart.
// An empty body is treated as an empty context (evaluates against no items).
func newPromotionEvaluateHandler(store *promotions.Store, svc *promotions.PromotionService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req promotions.EvaluateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
http.Error(w, "invalid JSON body: "+err.Error(), http.StatusBadRequest)
return
}
resp := svc.Evaluate(store.Snapshot(), req)
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"net/http" "net/http"
"time" "time"
"git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory" "github.com/matst80/go-redis-inventory/pkg/inventory"
) )
func getCurrency(country string) string { func getCurrency(country string) string {
+7 -55
View File
@@ -1,7 +1,6 @@
package main package main
import ( import (
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -10,8 +9,8 @@ import (
"net/url" "net/url"
"time" "time"
"git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/go-cart-actor/pkg/cart"
messages "git.k6n.net/mats/go-cart-actor/proto/checkout" messages "git.k6n.net/go-cart-actor/proto/checkout"
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout" adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout"
"github.com/adyen/adyen-go-api-library/v21/src/common" "github.com/adyen/adyen-go-api-library/v21/src/common"
"github.com/adyen/adyen-go-api-library/v21/src/hmacvalidator" "github.com/adyen/adyen-go-api-library/v21/src/hmacvalidator"
@@ -125,14 +124,6 @@ func (s *CheckoutPoolServer) AdyenHookHandler(w http.ResponseWriter, r *http.Req
} }
//} //}
// CAPTURE is the moment the Adyen payment is fully settled — the
// Adyen analogue of the Klarna push. Create the event-sourced
// order now (mirrors KlarnaPushHandler). Idempotent on
// "checkout-{checkoutId}", so a retried CAPTURE won't duplicate.
if isSuccess && s.orderClient != nil {
s.createAdyenOrder(r.Context(), *checkoutId, item)
}
case "AUTHORISATION": case "AUTHORISATION":
isSuccess := item.Success == "true" isSuccess := item.Success == "true"
@@ -185,11 +176,11 @@ func (s *CheckoutPoolServer) AdyenHookHandler(w http.ResponseWriter, r *http.Req
if err != nil { if err != nil {
log.Printf("Error capturing payment: %v", err) log.Printf("Error capturing payment: %v", err)
} else { } else {
// Capture requested. The order is NOT created here — we log.Printf("Payment captured successfully: %+v", res)
// only have a PSP reference, not an order. Adyen sends a s.ApplyAnywhere(r.Context(), *checkoutId, &messages.OrderCreated{
// CAPTURE notification once the capture settles, and the OrderId: res.PaymentPspReference,
// CAPTURE branch above creates the event-sourced order. Status: item.EventCode,
log.Printf("Payment capture requested successfully: %+v", res) })
} }
} }
default: default:
@@ -218,45 +209,6 @@ func (s *CheckoutPoolServer) AdyenHookHandler(w http.ResponseWriter, r *http.Req
} }
// createAdyenOrder turns a settled (captured) Adyen payment into an
// event-sourced order, mirroring KlarnaPushHandler. The Adyen webhook carries
// the charged amount + currency but not the shopper's country/locale, so those
// are derived from the currency. Failures are logged and left for the next
// CAPTURE notification to retry — the from-checkout endpoint is idempotent on
// "checkout-{checkoutId}".
func (s *CheckoutPoolServer) createAdyenOrder(ctx context.Context, checkoutId cart.CartId, item webhook.NotificationRequestItem) {
grain, err := s.GetAnywhere(ctx, checkoutId)
if err != nil {
log.Printf("from-checkout: could not load checkout grain %s: %v", checkoutId.String(), err)
return
}
// Reserve inventory once, guarded by the grain flag so a retried CAPTURE
// does not decrement stock twice. (Same as the Klarna path.)
if s.inventoryService != nil && grain.CartState != nil && !grain.InventoryReserved {
if rerr := s.inventoryService.ReserveInventory(ctx, getInventoryRequests(grain.CartState.Items)...); rerr != nil {
log.Printf("from-checkout: inventory reservation failed for %s: %v", checkoutId.String(), rerr)
} else {
s.Apply(ctx, uint64(grain.Id), &messages.InventoryReserved{
Id: grain.Id.String(),
Status: "success",
})
}
}
country := getCountryFromCurrency(item.Amount.Currency)
if _, oerr := createOrderFromSettledCheckout(ctx, s, grain, settledPayment{
Provider: "adyen",
Reference: item.PspReference,
Amount: item.Amount.Value,
Currency: item.Amount.Currency,
Country: country,
Locale: getLocale(country),
}); oerr != nil {
log.Printf("from-checkout failed for checkout %s; will retry on next CAPTURE: %v", checkoutId.String(), oerr)
}
}
func (s *CheckoutPoolServer) AdyenReturnHandler(w http.ResponseWriter, r *http.Request) { func (s *CheckoutPoolServer) AdyenReturnHandler(w http.ResponseWriter, r *http.Request) {
log.Println("Redirect received") log.Println("Redirect received")
+5 -13
View File
@@ -6,27 +6,20 @@ import (
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"os"
"time" "time"
"git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/go-cart-actor/pkg/cart"
) )
type CartClient struct { type CartClient struct {
httpClient *http.Client httpClient *http.Client
baseUrl string baseUrl string
agentProfile string // UCP-Agent profile URI
} }
func NewCartClient(baseUrl string) *CartClient { func NewCartClient(baseUrl string) *CartClient {
profile := os.Getenv("UCP_AGENT_PROFILE")
if profile == "" {
profile = "https://checkout.k6n.net/.well-known/ucp"
}
return &CartClient{ return &CartClient{
httpClient: &http.Client{Timeout: 10 * time.Second}, httpClient: &http.Client{Timeout: 10 * time.Second},
baseUrl: baseUrl, baseUrl: baseUrl,
agentProfile: profile,
} }
} }
@@ -59,7 +52,6 @@ func (s *CartClient) getCartGrain(ctx context.Context, cartId cart.CartId) (*car
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("UCP-Agent", `profile="`+s.agentProfile+`"`)
resp, err := s.httpClient.Do(req) resp, err := s.httpClient.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context" "context"
"testing" "testing"
"git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/go-cart-actor/pkg/cart"
) )
// TestGetCartGrain_RealService tests against the actual service at https://cart.k6n.net/ // TestGetCartGrain_RealService tests against the actual service at https://cart.k6n.net/
+19 -49
View File
@@ -4,50 +4,25 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"os"
"strings"
"git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/checkout" "git.k6n.net/go-cart-actor/pkg/checkout"
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout" adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout"
"github.com/adyen/adyen-go-api-library/v21/src/common" "github.com/adyen/adyen-go-api-library/v21/src/common"
) )
// defaultCallbackBaseURL is where Klarna's server-to-server callbacks
// (push/notification/validate) are sent when CHECKOUT_CALLBACK_BASE_URL is unset.
// These must be a publicly reachable https origin for the checkout service.
const defaultCallbackBaseURL = "https://cart.k6n.net"
var (
// checkoutPublicURL overrides the request-derived site base for the
// shopper-facing Klarna URLs (terms/checkout/confirmation). Set this to a
// public https origin (e.g. a tunnel or shop-test.tornberg.me) for local
// KCO testing, since Klarna rejects non-https merchant_urls. Empty = derive
// from the request (X-Forwarded-Host + https).
checkoutPublicURL = strings.TrimRight(os.Getenv("CHECKOUT_PUBLIC_URL"), "/")
// checkoutCallbackBaseURL is the base for Klarna's server-to-server
// callbacks. Defaults to defaultCallbackBaseURL.
checkoutCallbackBaseURL = func() string {
if v := strings.TrimRight(os.Getenv("CHECKOUT_CALLBACK_BASE_URL"), "/"); v != "" {
return v
}
return defaultCallbackBaseURL
}()
)
// CheckoutMeta carries the external / URL metadata required to build a // CheckoutMeta carries the external / URL metadata required to build a
// Klarna CheckoutOrder from a CartGrain snapshot. It deliberately excludes // Klarna CheckoutOrder from a CartGrain snapshot. It deliberately excludes
// any Klarna-specific response fields (HTML snippet, client token, etc.). // any Klarna-specific response fields (HTML snippet, client token, etc.).
type CheckoutMeta struct { type CheckoutMeta struct {
SiteUrl string SiteUrl string
// CallbackBaseUrl is the base for Klarna server-to-server callbacks // Terms string
// (push/notification/validate); may differ from SiteUrl in prod where the // Checkout string
// checkout service and storefront are on different hosts. // Confirmation string
CallbackBaseUrl string ClientIp string
ClientIp string Country string
Country string Currency string // optional override (defaults to "SEK" if empty)
Currency string // optional override (defaults to "SEK" if empty) Locale string // optional override (defaults to "sv-se" if empty)
Locale string // optional override (defaults to "sv-se" if empty)
} }
// BuildCheckoutOrderPayload converts the current cart grain + meta information // BuildCheckoutOrderPayload converts the current cart grain + meta information
@@ -138,11 +113,11 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta
MerchantReference1: grain.Id.String(), MerchantReference1: grain.Id.String(),
MerchantURLS: &CheckoutMerchantURLS{ MerchantURLS: &CheckoutMerchantURLS{
Terms: fmt.Sprintf("%s/terms", meta.SiteUrl), Terms: fmt.Sprintf("%s/terms", meta.SiteUrl),
Checkout: fmt.Sprintf("%s/kassa?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl), Checkout: fmt.Sprintf("%s/checkout?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl),
Confirmation: fmt.Sprintf("%s/kassa?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl), Confirmation: fmt.Sprintf("%s/checkout?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl),
Notification: fmt.Sprintf("%s/payment/klarna/notification", meta.CallbackBaseUrl), Notification: "https://cart.k6n.net/payment/klarna/notification",
Validation: fmt.Sprintf("%s/payment/klarna/validate", meta.CallbackBaseUrl), Validation: "https://cart.k6n.net/payment/klarna/validate",
Push: fmt.Sprintf("%s/payment/klarna/push?order_id={checkout.order.id}", meta.CallbackBaseUrl), Push: "https://cart.k6n.net/payment/klarna/push?order_id={checkout.order.id}",
}, },
} }
@@ -157,17 +132,12 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta
func GetCheckoutMetaFromRequest(r *http.Request) *CheckoutMeta { func GetCheckoutMetaFromRequest(r *http.Request) *CheckoutMeta {
host := getOriginalHost(r) host := getOriginalHost(r)
country := getCountryFromHost(host) country := getCountryFromHost(host)
siteUrl := fmt.Sprintf("%s://%s", getScheme(r), host)
if checkoutPublicURL != "" {
siteUrl = checkoutPublicURL
}
return &CheckoutMeta{ return &CheckoutMeta{
ClientIp: getClientIp(r), ClientIp: getClientIp(r),
SiteUrl: siteUrl, SiteUrl: fmt.Sprintf("https://%s", host),
CallbackBaseUrl: checkoutCallbackBaseURL, Country: country,
Country: country, Currency: getCurrency(country),
Currency: getCurrency(country), Locale: getLocale(country),
Locale: getLocale(country),
} }
} }
+1 -1
View File
@@ -3,7 +3,7 @@ package main
import ( import (
"log" "log"
"git.k6n.net/mats/go-cart-actor/pkg/discovery" "git.k6n.net/go-cart-actor/pkg/discovery"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
+23 -101
View File
@@ -8,10 +8,10 @@ import (
"net/http" "net/http"
"time" "time"
"git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/checkout" "git.k6n.net/go-cart-actor/pkg/checkout"
messages "git.k6n.net/mats/go-cart-actor/proto/checkout" messages "git.k6n.net/go-cart-actor/proto/checkout"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory" "github.com/matst80/go-redis-inventory/pkg/inventory"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
) )
@@ -183,20 +183,16 @@ func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Re
if s.inventoryService != nil { if s.inventoryService != nil {
inventoryRequests := getInventoryRequests(grain.CartState.Items) inventoryRequests := getInventoryRequests(grain.CartState.Items)
invStatus := "success" err = s.inventoryService.ReserveInventory(r.Context(), inventoryRequests...)
if err = s.inventoryService.ReserveInventory(r.Context(), inventoryRequests...); err != nil {
// The payment is already settled at Klarna (checkout_complete), so the if err != nil {
// order MUST be created. Inventory is best-effort at this point — a logger.WarnContext(r.Context(), "placeorder inventory reservation failed")
// reservation failure (unstocked / drop-ship / non-tracked items) w.WriteHeader(http.StatusNotAcceptable)
// must not block order creation. Log it and flag the grain so return
// fulfillment can reconcile.
logger.WarnContext(r.Context(), "klarna push: inventory reservation failed; creating order anyway",
"err", err, "checkoutId", grain.Id.String())
invStatus = "failed"
} }
s.Apply(r.Context(), uint64(grain.Id), &messages.InventoryReserved{ s.Apply(r.Context(), uint64(grain.Id), &messages.InventoryReserved{
Id: grain.Id.String(), Id: grain.Id.String(),
Status: invStatus, Status: "success",
}) })
} }
@@ -209,24 +205,19 @@ func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Re
CompletedAt: timestamppb.Now(), CompletedAt: timestamppb.Now(),
}) })
// ── Create the event-sourced order grain (dual-write with AMQP) ──── // err = confirmOrder(r.Context(), order, orderHandler)
if s.orderClient != nil { // if err != nil {
if _, orderErr := createOrderFromCheckout(r.Context(), s, grain, order, "klarna"); orderErr != nil { // log.Printf("Error confirming order: %v\n", err)
// Non-fatal: the checkout grain is updated, the order can be // w.WriteHeader(http.StatusInternalServerError)
// created on retry (idempotency key guards against duplicates). // return
logger.WarnContext(r.Context(), "from-checkout failed; will retry on next push", // }
"err", orderErr, "checkoutId", grain.Id.String())
}
}
// ── Dual-write: AMQP order-queue for legacy consumers ───────────────
if s.orderHandler != nil {
legacyBody, _ := buildLegacyOrderJSON(grain, order, "klarna", orderId)
if pubErr := s.orderHandler.OrderCompleted(legacyBody); pubErr != nil {
logger.WarnContext(r.Context(), "order-queue publish failed", "err", pubErr)
}
}
// 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 = s.klarnaClient.AcknowledgeOrder(r.Context(), orderId) err = s.klarnaClient.AcknowledgeOrder(r.Context(), orderId)
if err != nil { if err != nil {
log.Printf("Error acknowledging order: %v\n", err) log.Printf("Error acknowledging order: %v\n", err)
@@ -249,75 +240,6 @@ var tpl = `<!DOCTYPE html>
</html> </html>
` `
// createOrderFromCheckout builds and sends the from-checkout request to the
// order service, creating an event-sourced order grain from the settled checkout.
// The idempotency key is "checkout-{checkoutId}" so that retries (Klarna may
// push the same order multiple times) are safe.
func createOrderFromCheckout(ctx context.Context, s *CheckoutPoolServer, grain *checkout.CheckoutGrain, klarnaOrder *CheckoutOrder, provider string) (*CreateOrderResult, error) {
return createOrderFromSettledCheckout(ctx, s, grain, settledPayment{
Provider: provider,
Reference: klarnaOrder.ID,
Amount: int64(klarnaOrder.OrderAmount),
Currency: klarnaOrder.PurchaseCurrency,
Country: klarnaOrder.PurchaseCountry,
Locale: klarnaOrder.Locale,
})
}
// buildLegacyOrderJSON builds the go-order-manager compatible JSON for AMQP
// dual-write, so existing downstream consumers on the order-queue still receive
// order events during the migration period.
func buildLegacyOrderJSON(grain *checkout.CheckoutGrain, klarnaOrder *CheckoutOrder, provider string, orderId string) ([]byte, error) {
if grain.CartState == nil {
return nil, fmt.Errorf("buildLegacyOrderJSON: checkout %s has no cart state", grain.Id)
}
type legacyLine struct {
Reference string `json:"reference"`
Name string `json:"name"`
Quantity int `json:"quantity"`
UnitPrice int `json:"unit_price"`
TaxRate int `json:"tax_rate"`
TotalAmount int `json:"total_amount"`
TotalTaxAmount int `json:"total_tax_amount"`
}
type legacyOrder struct {
ID string `json:"order_id"`
PurchaseCountry string `json:"purchase_country"`
PurchaseCurrency string `json:"purchase_currency"`
Locale string `json:"locale"`
OrderAmount int `json:"order_amount"`
OrderTaxAmount int `json:"order_tax_amount"`
OrderLines []legacyLine `json:"order_lines"`
}
lines := make([]legacyLine, 0, len(grain.CartState.Items)+len(grain.Deliveries))
for _, it := range grain.CartState.Items {
if it == nil {
continue
}
lines = append(lines, legacyLine{
Reference: it.Sku,
Name: it.Meta.Name,
Quantity: int(it.Quantity),
UnitPrice: int(it.Price.IncVat),
TaxRate: it.Tax,
TotalAmount: int(it.TotalPrice.IncVat),
TotalTaxAmount: int(it.TotalPrice.TotalVat()),
})
}
lo := legacyOrder{
ID: orderId,
PurchaseCountry: klarnaOrder.PurchaseCountry,
PurchaseCurrency: klarnaOrder.PurchaseCurrency,
Locale: klarnaOrder.Locale,
OrderAmount: klarnaOrder.OrderAmount,
OrderTaxAmount: klarnaOrder.OrderTaxAmount,
OrderLines: lines,
}
return json.Marshal(lo)
}
func getLocationId(item *cart.CartItem) inventory.LocationID { func getLocationId(item *cart.CartItem) inventory.LocationID {
if item.StoreId == nil || *item.StoreId == "" { if item.StoreId == nil || *item.StoreId == "" {
return "se" return "se"
+6 -68
View File
@@ -10,14 +10,12 @@ import (
"os/signal" "os/signal"
"time" "time"
"git.k6n.net/mats/go-cart-actor/internal/ucp" "git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/go-cart-actor/pkg/checkout"
"git.k6n.net/mats/go-cart-actor/pkg/checkout" "git.k6n.net/go-cart-actor/pkg/proxy"
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
"github.com/adyen/adyen-go-api-library/v21/src/adyen" "github.com/adyen/adyen-go-api-library/v21/src/adyen"
"github.com/adyen/adyen-go-api-library/v21/src/common" "github.com/adyen/adyen-go-api-library/v21/src/common"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory" "github.com/matst80/go-redis-inventory/pkg/inventory"
amqp "github.com/rabbitmq/amqp091-go"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promauto"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
@@ -49,26 +47,6 @@ var redisAddress = os.Getenv("REDIS_ADDRESS")
var redisPassword = os.Getenv("REDIS_PASSWORD") var redisPassword = os.Getenv("REDIS_PASSWORD")
var cartInternalUrl = os.Getenv("CART_INTERNAL_URL") // e.g., http://cart-service:8081 var cartInternalUrl = os.Getenv("CART_INTERNAL_URL") // e.g., http://cart-service:8081
// loadUCPCheckoutSigner loads the UCP ECDSA signing key from the path specified in
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
func loadUCPCheckoutSigner() *ucp.SigningConfig {
path := os.Getenv("UCP_SIGNING_KEY_PATH")
if path == "" {
return nil
}
pemData, err := os.ReadFile(path)
if err != nil {
log.Printf("ucp signing: cannot read key %s: %v", path, err)
return nil
}
cfg, err := ucp.NewSigningConfigFromPEM(pemData, "k6n-ecdsa-2026")
if err != nil {
log.Printf("ucp signing: invalid key at %s: %v", path, err)
return nil
}
return cfg
}
func main() { func main() {
controlPlaneConfig := actor.DefaultServerConfig() controlPlaneConfig := actor.DefaultServerConfig()
@@ -90,11 +68,7 @@ func main() {
log.Fatalf("Error creating inventory service: %v\n", err) log.Fatalf("Error creating inventory service: %v\n", err)
} }
checkoutDir := os.Getenv("CHECKOUT_DIR") diskStorage := actor.NewDiskStorage[checkout.CheckoutGrain]("data", reg)
if checkoutDir == "" {
checkoutDir = "data"
}
diskStorage := actor.NewDiskStorage[checkout.CheckoutGrain](checkoutDir, reg)
poolConfig := actor.GrainPoolConfig[checkout.CheckoutGrain]{ poolConfig := actor.GrainPoolConfig[checkout.CheckoutGrain]{
MutationRegistry: reg, MutationRegistry: reg,
@@ -137,23 +111,7 @@ func main() {
cartClient := NewCartClient(cartInternalUrl) cartClient := NewCartClient(cartInternalUrl)
var orderClient *OrderClient syncedServer := NewCheckoutPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, cartClient, adyenClient)
if orderServiceURL := os.Getenv("ORDER_SERVICE_URL"); orderServiceURL != "" {
orderClient = NewOrderClient(orderServiceURL)
log.Printf("order client enabled: %s", orderServiceURL)
}
var orderHandler *AmqpOrderHandler
conn, err := amqp.Dial(amqpUrl)
if err != nil {
log.Fatalf("failed to connect to RabbitMQ: %v", err)
}
orderHandler = NewAmqpOrderHandler(conn)
if err := orderHandler.DefineQueue(); err != nil {
log.Fatalf("failed to declare order queue: %v", err)
}
syncedServer := NewCheckoutPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, cartClient, adyenClient, orderClient, orderHandler)
syncedServer.inventoryService = inventoryService syncedServer.inventoryService = inventoryService
mux := http.NewServeMux() mux := http.NewServeMux()
@@ -181,26 +139,6 @@ func main() {
syncedServer.Serve(mux) syncedServer.Serve(mux)
// UCP REST adapter — Universal Commerce Protocol checkout sessions
// When an order service URL is configured, the complete endpoint creates
// real orders via the order service's from-checkout API.
var ucpOrderSvc ucp.OrderApplier
if orderServiceURL := os.Getenv("ORDER_SERVICE_URL"); orderServiceURL != "" {
ucpOrderSvc = ucp.NewOrderHTTPClient(orderServiceURL)
log.Printf("ucp order applier enabled: %s", orderServiceURL)
}
checkoutUCP := ucp.CheckoutHandler(pool, ucpOrderSvc)
if signer := loadUCPCheckoutSigner(); signer != nil {
checkoutUCP = ucp.WithSigning(checkoutUCP, signer)
log.Print("ucp signing enabled")
}
// StripPrefix is required because the sub-mux (CheckoutHandler) registers
// routes like "GET /{id}" which expect a relative path; Go's ServeMux
// passes the full request path without stripping the prefix.
// Only the subtree pattern is registered (see comment in cmd/cart/main.go
// for why the exact-match pattern is omitted).
mux.Handle("/ucp/v1/checkout-sessions/", http.StripPrefix("/ucp/v1/checkout-sessions", checkoutUCP))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
grainCount, capacity := pool.LocalUsage() grainCount, capacity := pool.LocalUsage()
if grainCount >= capacity { if grainCount >= capacity {
-120
View File
@@ -1,120 +0,0 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
// OrderClient is a minimal HTTP client for the order service. It is the
// checkout service's counterpart to the CartClient — the same pattern, but
// calling into order-service endpoints instead of cart-service ones.
//
// Currently only CreateOrder is implemented (the from-checkout handoff).
// Add GetOrder/ListOrders methods as the backoffice order UI evolves.
type OrderClient struct {
httpClient *http.Client
baseURL string // e.g. "http://order-service:8092"
agentProfile string // UCP-Agent profile URI
}
// NewOrderClient returns a client that talks to the order service at baseURL.
func NewOrderClient(baseURL string) *OrderClient {
profile := os.Getenv("UCP_AGENT_PROFILE")
if profile == "" {
profile = "https://cart.k6n.net/.well-known/ucp"
}
return &OrderClient{
httpClient: &http.Client{Timeout: 30 * time.Second},
baseURL: baseURL,
agentProfile: profile,
}
}
// CreateOrderReq is the JSON payload POSTed to the order service's
// /api/orders/from-checkout endpoint. Every field is required except where
// noted. See cmd/order/handlers_checkout.go for the full definition.
type CreateOrderReq struct {
CheckoutId string `json:"checkoutId"`
IdempotencyKey string `json:"idempotencyKey"`
CartId string `json:"cartId"`
Currency string `json:"currency"`
Locale string `json:"locale,omitempty"`
Country string `json:"country"`
CustomerEmail string `json:"customerEmail,omitempty"`
CustomerName string `json:"customerName,omitempty"`
Lines []OrderLine `json:"lines"`
Payment struct {
Provider string `json:"provider"`
Reference string `json:"reference"`
Amount int64 `json:"amount"`
} `json:"payment"`
}
// OrderLine is one orderable item in the CreateOrderReq, matching the
// lineReq type in the order service.
type OrderLine struct {
Reference string `json:"reference"`
Sku string `json:"sku"`
Name string `json:"name"`
Quantity int32 `json:"quantity"`
UnitPrice int64 `json:"unitPrice"`
TaxRate int32 `json:"taxRate"`
}
// CreateOrderResult is the success response from POST /api/orders/from-checkout.
type CreateOrderResult struct {
OrderId string `json:"orderId"`
Flow json.RawMessage `json:"flow,omitempty"`
Order json.RawMessage `json:"order,omitempty"`
// Existing is true when the response is a 409 Conflict — the idempotency
// key was already used and this is the previously-created order.
Existing bool `json:"existing,omitempty"`
}
// CreateOrder sends a settled checkout's data to the order service and returns
// the created (or previously-created, via idempotency) order. An idempotencyKey
// should be generated per checkout (e.g. "checkout-{checkoutId}") to guard
// against retries. flowName overrides the default flow; use "" for the default.
func (c *OrderClient) CreateOrder(ctx context.Context, req CreateOrderReq, flowName string) (*CreateOrderResult, error) {
url := c.baseURL + "/api/orders/from-checkout"
if flowName != "" {
url += "?flow=" + flowName
}
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("order client: marshal: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("order client: new request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("UCP-Agent", `profile="`+c.agentProfile+`"`)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("order client: do: %w", err)
}
defer resp.Body.Close()
// Both 201 (created) and 409 (idempotent hit) are valid responses.
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusConflict {
return nil, fmt.Errorf("order client: %s", resp.Status)
}
var result CreateOrderResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("order client: decode: %w", err)
}
result.Existing = resp.StatusCode == http.StatusConflict
return &result, nil
}
-110
View File
@@ -1,110 +0,0 @@
package main
import (
"context"
"fmt"
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
"google.golang.org/protobuf/types/known/timestamppb"
)
// settledPayment carries the provider-agnostic facts about an already-settled
// payment. Each payment integration (Klarna push, Adyen CAPTURE notification,
// …) fills this in from its own callback and hands it to
// createOrderFromSettledCheckout, so the from-checkout request is identical
// regardless of which provider settled the payment.
type settledPayment struct {
Provider string // "klarna" | "adyen"
Reference string // processor / PSP reference
Amount int64 // minor units, as charged
Currency string
Country string
Locale string
}
// buildOrderLinesFromGrain converts the checkout grain's cart items and delivery
// selections into from-checkout order lines. Shared by every provider so the
// order payload is built the same way no matter who settled the payment.
func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
lines := make([]OrderLine, 0, len(grain.CartState.Items)+len(grain.Deliveries))
for _, it := range grain.CartState.Items {
if it == nil {
continue
}
lines = append(lines, OrderLine{
Reference: it.Sku,
Sku: it.Sku,
Name: it.Meta.Name,
Quantity: int32(it.Quantity),
UnitPrice: it.Price.IncVat,
TaxRate: int32(it.Tax),
})
}
for _, d := range grain.Deliveries {
if d == nil || d.Price.IncVat <= 0 {
continue
}
lines = append(lines, OrderLine{
Reference: d.Provider,
Sku: d.Provider,
Name: "Delivery",
Quantity: 1,
UnitPrice: d.Price.IncVat,
TaxRate: 2500,
})
}
return lines
}
// createOrderFromSettledCheckout sends the from-checkout request for a settled
// checkout (any provider) and, on success, records the returned order id on the
// checkout grain via the OrderCreated mutation so the backoffice can link
// checkout → order.
//
// It is idempotent: the order service dedupes on "checkout-{checkoutId}", so
// repeated provider callbacks (Klarna re-push, retried Adyen CAPTURE) return the
// existing order rather than creating a duplicate. Callers should treat a
// non-nil error as non-fatal and retry on the next callback.
func createOrderFromSettledCheckout(ctx context.Context, s *CheckoutPoolServer, grain *checkout.CheckoutGrain, pay settledPayment) (*CreateOrderResult, error) {
if grain.CartState == nil {
return nil, fmt.Errorf("from-checkout: checkout %s has no cart state", grain.Id)
}
var customerEmail, customerName string
if grain.ContactDetails != nil {
if grain.ContactDetails.Email != nil {
customerEmail = *grain.ContactDetails.Email
}
if grain.ContactDetails.Name != nil {
customerName = *grain.ContactDetails.Name
}
}
req := CreateOrderReq{
CheckoutId: grain.Id.String(),
IdempotencyKey: "checkout-" + grain.Id.String(),
CartId: grain.CartId.String(),
Currency: pay.Currency,
Locale: pay.Locale,
Country: pay.Country,
CustomerEmail: customerEmail,
CustomerName: customerName,
Lines: buildOrderLinesFromGrain(grain),
}
req.Payment.Provider = pay.Provider
req.Payment.Reference = pay.Reference
req.Payment.Amount = pay.Amount
result, err := s.orderClient.CreateOrder(ctx, req, "")
if err != nil {
return nil, err
}
_ = s.ApplyAnywhere(ctx, grain.Id, &messages.OrderCreated{
OrderId: result.OrderId,
Status: "completed",
CreatedAt: timestamppb.Now(),
})
return result, nil
}
+25 -31
View File
@@ -5,18 +5,20 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"log"
"net/http" "net/http"
"os" "os"
"strconv" "strconv"
"time" "time"
"git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/checkout" "git.k6n.net/go-cart-actor/pkg/checkout"
messages "git.k6n.net/mats/go-cart-actor/proto/checkout" messages "git.k6n.net/go-cart-actor/proto/checkout"
adyen "github.com/adyen/adyen-go-api-library/v21/src/adyen" adyen "github.com/adyen/adyen-go-api-library/v21/src/adyen"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory" "github.com/matst80/go-redis-inventory/pkg/inventory"
amqp "github.com/rabbitmq/amqp091-go"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promauto"
@@ -49,20 +51,16 @@ type CheckoutPoolServer struct {
klarnaClient *KlarnaClient klarnaClient *KlarnaClient
adyenClient *adyen.APIClient adyenClient *adyen.APIClient
cartClient *CartClient cartClient *CartClient
orderClient *OrderClient
orderHandler *AmqpOrderHandler
inventoryService *inventory.RedisInventoryService inventoryService *inventory.RedisInventoryService
} }
func NewCheckoutPoolServer(pool actor.GrainPool[checkout.CheckoutGrain], pod_name string, klarnaClient *KlarnaClient, cartClient *CartClient, adyenClient *adyen.APIClient, orderClient *OrderClient, orderHandler *AmqpOrderHandler) *CheckoutPoolServer { func NewCheckoutPoolServer(pool actor.GrainPool[checkout.CheckoutGrain], pod_name string, klarnaClient *KlarnaClient, cartClient *CartClient, adyenClient *adyen.APIClient) *CheckoutPoolServer {
srv := &CheckoutPoolServer{ srv := &CheckoutPoolServer{
GrainPool: pool, GrainPool: pool,
pod_name: pod_name, pod_name: pod_name,
klarnaClient: klarnaClient, klarnaClient: klarnaClient,
cartClient: cartClient, cartClient: cartClient,
adyenClient: adyenClient, adyenClient: adyenClient,
orderClient: orderClient,
orderHandler: orderHandler,
} }
return srv return srv
@@ -197,19 +195,6 @@ func (s *CheckoutPoolServer) ContactDetailsUpdatedHandler(w http.ResponseWriter,
return s.WriteResult(w, result) return s.WriteResult(w, result)
} }
func (s *CheckoutPoolServer) CancelPaymentHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
paymentId := r.PathValue("id")
result, err := s.ApplyLocal(r.Context(), checkoutId, &messages.CancelPayment{
PaymentId: paymentId,
CancelledAt: timestamppb.New(time.Now()),
})
if err != nil {
return err
}
return s.WriteResult(w, result)
}
func (s *CheckoutPoolServer) StartCheckoutHandler(w http.ResponseWriter, r *http.Request) { func (s *CheckoutPoolServer) StartCheckoutHandler(w http.ResponseWriter, r *http.Request) {
cartIdStr := r.PathValue("cartid") cartIdStr := r.PathValue("cartid")
if cartIdStr == "" { if cartIdStr == "" {
@@ -239,13 +224,15 @@ func (s *CheckoutPoolServer) StartCheckoutHandler(w http.ResponseWriter, r *http
return return
} }
// The checkout grain is 1:1 with the cart it checks out (CheckoutId == // Create checkout with same ID as cart
// CartId). Keying it by the cart id — not a lingering checkoutid cookie — var checkoutId checkout.CheckoutId = cart.MustNewCartId()
// means a fresh cart always gets a fresh checkout, so the Klarna order cookie, err := r.Cookie(checkoutCookieName)
// reflects the cart shown at /kassa. Reusing the cookie made a new cart's if err == nil {
// checkout latch onto a previous cart's frozen snapshot (InitializeCheckout parsed, ok := cart.ParseCartId(cookie.Value)
// silently refuses to re-snapshot a grain that already has a CartState). if ok {
checkoutId := checkout.CheckoutId(cartId) checkoutId = parsed
}
}
// Initialize checkout with cart state wrapped in Any // Initialize checkout with cart state wrapped in Any
cartStateAny := &messages.InitializeCheckout{ cartStateAny := &messages.InitializeCheckout{
@@ -527,6 +514,14 @@ func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) {
handleFunc("/payment/klarna/push", s.KlarnaPushHandler) handleFunc("/payment/klarna/push", s.KlarnaPushHandler)
handleFunc("/payment/klarna/notification", s.KlarnaNotificationHandler) handleFunc("/payment/klarna/notification", s.KlarnaNotificationHandler)
conn, err := amqp.Dial(amqpUrl)
if err != nil {
log.Fatalf("failed to connect to RabbitMQ: %v", err)
}
orderHandler := NewAmqpOrderHandler(conn)
orderHandler.DefineQueue()
handleFunc("POST /api/checkout/start/{cartid}", s.StartCheckoutHandler) handleFunc("POST /api/checkout/start/{cartid}", s.StartCheckoutHandler)
handleFunc("GET /api/checkout", CookieCheckoutIdHandler(s.ProxyHandler(s.GetCheckoutHandler))) handleFunc("GET /api/checkout", CookieCheckoutIdHandler(s.ProxyHandler(s.GetCheckoutHandler)))
handleFunc("POST /api/checkout/delivery", CookieCheckoutIdHandler(s.ProxyHandler(s.SetDeliveryHandler))) handleFunc("POST /api/checkout/delivery", CookieCheckoutIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
@@ -535,7 +530,6 @@ func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) {
handleFunc("POST /api/checkout/contact-details", CookieCheckoutIdHandler(s.ProxyHandler(s.ContactDetailsUpdatedHandler))) handleFunc("POST /api/checkout/contact-details", CookieCheckoutIdHandler(s.ProxyHandler(s.ContactDetailsUpdatedHandler)))
handleFunc("POST /payment", CookieCheckoutIdHandler(s.ProxyHandler(s.StartPaymentHandler))) handleFunc("POST /payment", CookieCheckoutIdHandler(s.ProxyHandler(s.StartPaymentHandler)))
handleFunc("POST /payment/{id}/session", CookieCheckoutIdHandler(s.ProxyHandler(s.GetPaymentSessionHandler))) handleFunc("POST /payment/{id}/session", CookieCheckoutIdHandler(s.ProxyHandler(s.GetPaymentSessionHandler)))
handleFunc("DELETE /payment/{id}", CookieCheckoutIdHandler(s.ProxyHandler(s.CancelPaymentHandler)))
// handleFunc("POST /api/checkout/initialize", CookieCheckoutIdHandler(s.ProxyHandler(s.InitializeCheckoutHandler))) // handleFunc("POST /api/checkout/initialize", CookieCheckoutIdHandler(s.ProxyHandler(s.InitializeCheckoutHandler)))
// handleFunc("POST /api/checkout/inventory-reserved", CookieCheckoutIdHandler(s.ProxyHandler(s.InventoryReservedHandler))) // handleFunc("POST /api/checkout/inventory-reserved", CookieCheckoutIdHandler(s.ProxyHandler(s.InventoryReservedHandler)))
// handleFunc("POST /api/checkout/order-created", CookieCheckoutIdHandler(s.ProxyHandler(s.OrderCreatedHandler))) // handleFunc("POST /api/checkout/order-created", CookieCheckoutIdHandler(s.ProxyHandler(s.OrderCreatedHandler)))
+2 -25
View File
@@ -7,8 +7,8 @@ import (
"strings" "strings"
"time" "time"
"git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/checkout" "git.k6n.net/go-cart-actor/pkg/checkout"
"go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/metric"
) )
@@ -21,19 +21,6 @@ func getOriginalHost(r *http.Request) string {
return r.Host return r.Host
} }
// getScheme resolves the public scheme for building absolute URLs (e.g. Klarna
// merchant_urls). Honors X-Forwarded-Proto set by the edge/dev proxy; defaults
// to https so production (TLS terminated at nginx/ingress, no header) is correct.
func getScheme(r *http.Request) string {
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
return proto
}
if r.TLS != nil {
return "https"
}
return "https"
}
func getClientIp(r *http.Request) string { func getClientIp(r *http.Request) string {
ip := r.Header.Get("X-Forwarded-For") ip := r.Header.Get("X-Forwarded-For")
if ip == "" { if ip == "" {
@@ -56,16 +43,6 @@ func getLocale(country string) string {
return "sv-se" return "sv-se"
} }
// getCountryFromCurrency is the reverse of getCurrency, used by server-to-server
// callbacks (e.g. the Adyen webhook) that carry the settled currency but not the
// shopper's host/country.
func getCountryFromCurrency(currency string) string {
if strings.EqualFold(currency, "NOK") {
return "no"
}
return "se"
}
func getCountryFromHost(host string) string { func getCountryFromHost(host string) string {
if strings.Contains(strings.ToLower(host), "-no") { if strings.Contains(strings.ToLower(host), "-no") {
return "no" return "no"
+15 -9
View File
@@ -8,9 +8,9 @@ import (
"os" "os"
"sync" "sync"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory" "github.com/matst80/go-redis-inventory/pkg/inventory"
"git.k6n.net/mats/slask-finder/pkg/index" "github.com/matst80/slask-finder/pkg/index"
"git.k6n.net/mats/slask-finder/pkg/messaging" "github.com/matst80/slask-finder/pkg/messaging"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"github.com/redis/go-redis/v9/maintnotifications" "github.com/redis/go-redis/v9/maintnotifications"
@@ -132,14 +132,20 @@ func main() {
} }
// items listener // items listener
err = messaging.ListenToTopic(ch, country, "item_added", func(d amqp.Delivery) error { err = messaging.ListenToTopic(ch, country, "item_added", func(d amqp.Delivery) error {
wg := &sync.WaitGroup{} var items []*index.DataItem
err = index.ForEachRawDataItemInJSONArray(d.Body, func(item *index.RawDataItem) error { err := json.Unmarshal(d.Body, &items)
stockhandler.HandleItem(item, wg) 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() wg.Wait()
log.Print("Batch done...") log.Print("Batch done...")
return err } else {
}) log.Printf("Failed to unmarshal upsert message %v", err)
return nil }
return err
}) })
if err != nil { if err != nil {
log.Fatalf("Failed to listen to item_added topic: %v", err) log.Fatalf("Failed to listen to item_added topic: %v", err)
+17 -12
View File
@@ -3,10 +3,12 @@ package main
import ( import (
"context" "context"
"log" "log"
"strconv"
"strings"
"sync" "sync"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory" "github.com/matst80/go-redis-inventory/pkg/inventory"
"git.k6n.net/mats/slask-finder/pkg/types" "github.com/matst80/slask-finder/pkg/types"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
) )
@@ -21,22 +23,25 @@ func (s *StockHandler) HandleItem(item types.Item, wg *sync.WaitGroup) {
wg.Go(func() { wg.Go(func() {
ctx := s.ctx ctx := s.ctx
pipe := s.rdb.Pipeline() pipe := s.rdb.Pipeline()
centralStock, ok := item.GetNumberFieldValue("inStock") centralStockString, ok := item.GetStringFieldValue(3)
if !ok { if !ok {
centralStock = 0 centralStockString = "0"
} }
centralStockString = strings.Replace(centralStockString, "+", "", -1)
centralStockString = strings.Replace(centralStockString, "<", "", -1)
centralStockString = strings.Replace(centralStockString, ">", "", -1)
centralStock, err := strconv.ParseInt(centralStockString, 10, 64)
sku, ok := item.GetStringFieldValue("sku") if err != nil {
if !ok { log.Printf("unable to parse central stock for item %s: %v", item.GetSku(), err)
log.Printf("unable to parse central stock for item %s: %v", sku)
centralStock = 0 centralStock = 0
} else { } else {
s.svc.UpdateInventory(ctx, pipe, inventory.SKU(sku), s.MainStockLocationID, int64(centralStock)) s.svc.UpdateInventory(ctx, pipe, inventory.SKU(item.GetSku()), s.MainStockLocationID, int64(centralStock))
} }
// for id, value := range item.GetStock() { for id, value := range item.GetStock() {
// s.svc.UpdateInventory(ctx, pipe, inventory.SKU(sku), inventory.LocationID(id), int64(value)) s.svc.UpdateInventory(ctx, pipe, inventory.SKU(item.GetSku()), inventory.LocationID(id), int64(value))
// } }
_, err := pipe.Exec(ctx) _, err = pipe.Exec(ctx)
if err != nil { if err != nil {
log.Printf("unable to update stock: %v", err) log.Printf("unable to update stock: %v", err)
} }
-223
View File
@@ -1,223 +0,0 @@
package main
import (
"context"
"encoding/json"
"hash/fnv"
"log/slog"
"sync"
"time"
messages "git.k6n.net/mats/go-cart-actor/proto/order"
amqp "github.com/rabbitmq/amqp091-go"
)
// rabbitPublisher is the broker-delivery side used by the outbox relay. It dials
// lazily and reconnects on failure, so the relay can be started even when the
// broker is down at boot: messages accumulate durably in the outbox and drain
// once the broker is reachable.
type rabbitPublisher struct {
url string
mu sync.Mutex
conn *amqp.Connection
ch *amqp.Channel
}
func newRabbitPublisher(url string) *rabbitPublisher {
return &rabbitPublisher{url: url}
}
func (p *rabbitPublisher) connect() error {
conn, err := amqp.Dial(p.url)
if err != nil {
return err
}
ch, err := conn.Channel()
if err != nil {
_ = conn.Close()
return err
}
p.conn, p.ch = conn, ch
return nil
}
func (p *rabbitPublisher) reset() {
if p.ch != nil {
_ = p.ch.Close()
}
if p.conn != nil {
_ = p.conn.Close()
}
p.ch, p.conn = nil, nil
}
func (p *rabbitPublisher) Publish(exchange, routingKey string, body []byte) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.ch == nil {
if err := p.connect(); err != nil {
return err
}
}
// Bound the publish so a half-open/black-holed connection can't wedge the
// outbox relay indefinitely; on timeout the error path resets and the relay
// retries on its next tick.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := p.ch.PublishWithContext(ctx, exchange, routingKey, false, false,
amqp.Publishing{ContentType: "application/json", Body: body})
if err != nil {
p.reset() // force a reconnect on the next attempt
}
return err
}
// This ingester lets the order service supersede the legacy go-order-manager:
// it consumes the same "order-queue" the Klarna/Adyen checkout publishes to and
// records each completed order as an event-sourced grain. Those orders are
// already paid by the external processor, so we record payment with provider
// "legacy" (place -> authorize -> capture) rather than charging again.
// legacyOrder is the subset of go-order-manager's Order JSON we need.
type legacyOrder struct {
ID string `json:"order_id"`
PurchaseCountry string `json:"purchase_country"`
PurchaseCurrency string `json:"purchase_currency"`
Locale string `json:"locale"`
OrderAmount int64 `json:"order_amount"`
OrderTaxAmount int64 `json:"order_tax_amount"`
OrderLines []legacyLine `json:"order_lines"`
Customer *legacyPerson `json:"customer,omitempty"`
BillingAddress *legacyAddr `json:"billing_address,omitempty"`
}
type legacyLine struct {
Reference string `json:"reference"`
Name string `json:"name"`
Quantity int32 `json:"quantity"`
UnitPrice int64 `json:"unit_price"`
TaxRate int32 `json:"tax_rate"`
TotalAmount int64 `json:"total_amount"`
TotalTaxAmount int64 `json:"total_tax_amount"`
}
type legacyPerson struct {
Email string `json:"email,omitempty"`
}
type legacyAddr struct {
Email string `json:"email,omitempty"`
GivenName string `json:"given_name,omitempty"`
FamilyName string `json:"family_name,omitempty"`
}
// legacyOrderID maps a legacy string order id to a stable grain id, so a
// redelivered message resolves to the same grain (PlaceOrder then no-ops).
func legacyOrderID(ref string) uint64 {
h := fnv.New64a()
_, _ = h.Write([]byte(ref))
id := h.Sum64()
if id == 0 {
id = 1
}
return id
}
// ingestLegacyOrder records one legacy order as an event-sourced grain. It is
// idempotent: if the order is already placed, PlaceOrder is rejected and we
// stop (the order already exists). Testable without a broker.
func ingestLegacyOrder(ctx context.Context, app *orderedApplier, body []byte) error {
var lo legacyOrder
if err := json.Unmarshal(body, &lo); err != nil {
return err
}
if lo.ID == "" || len(lo.OrderLines) == 0 {
return nil // nothing actionable
}
id := legacyOrderID(lo.ID)
ms := time.Now().UnixMilli()
po := &messages.PlaceOrder{
OrderReference: lo.ID,
Currency: lo.PurchaseCurrency,
Locale: lo.Locale,
Country: lo.PurchaseCountry,
TotalAmount: lo.OrderAmount,
TotalTax: lo.OrderTaxAmount,
PlacedAtMs: ms,
}
if lo.Customer != nil {
po.CustomerEmail = lo.Customer.Email
}
if po.CustomerEmail == "" && lo.BillingAddress != nil {
po.CustomerEmail = lo.BillingAddress.Email
po.CustomerName = lo.BillingAddress.GivenName + " " + lo.BillingAddress.FamilyName
}
for _, l := range lo.OrderLines {
po.Lines = append(po.Lines, &messages.OrderLine{
Reference: l.Reference, Sku: l.Reference, Name: l.Name,
Quantity: l.Quantity, UnitPrice: l.UnitPrice, TaxRate: l.TaxRate,
TotalAmount: l.TotalAmount, TotalTax: l.TotalTaxAmount,
})
}
if err := applyOne(ctx, app, id, po); err != nil {
// Already placed (redelivery) or invalid — nothing more to do.
return nil
}
// Record the externally-settled payment so the grain reaches "captured".
ref := "legacy-" + lo.ID
_ = applyOne(ctx, app, id, &messages.AuthorizePayment{Provider: "legacy", Amount: lo.OrderAmount, Reference: ref, AtMs: ms})
_ = applyOne(ctx, app, id, &messages.CapturePayment{Provider: "legacy", Amount: lo.OrderAmount, Reference: ref, AtMs: ms})
return nil
}
// startLegacyIngest connects to AMQP and consumes "order-queue" until ctx is
// done. Best-effort: a connection failure is logged and ingestion is skipped
// (the HTTP checkout path keeps working regardless).
func startLegacyIngest(ctx context.Context, amqpURL string, app *orderedApplier, logger *slog.Logger) {
conn, err := amqp.Dial(amqpURL)
if err != nil {
logger.Warn("legacy order ingest disabled: amqp dial failed", "err", err)
return
}
ch, err := conn.Channel()
if err != nil {
logger.Warn("legacy order ingest disabled: channel failed", "err", err)
_ = conn.Close()
return
}
q, err := ch.QueueDeclare("order-queue", false, false, false, false, nil)
if err != nil {
logger.Warn("legacy order ingest disabled: queue declare failed", "err", err)
_ = ch.Close()
_ = conn.Close()
return
}
msgs, err := ch.Consume(q.Name, "order-service", true, false, false, false, nil)
if err != nil {
logger.Warn("legacy order ingest disabled: consume failed", "err", err)
_ = ch.Close()
_ = conn.Close()
return
}
logger.Info("ingesting legacy orders from order-queue")
go func() {
defer conn.Close()
defer ch.Close()
for {
select {
case <-ctx.Done():
return
case d, ok := <-msgs:
if !ok {
logger.Warn("legacy order ingest: channel closed")
return
}
if err := ingestLegacyOrder(ctx, app, d.Body); err != nil {
logger.Error("legacy order ingest failed", "err", err)
}
}
}
}()
}
-73
View File
@@ -1,73 +0,0 @@
package main
import (
"context"
"fmt"
"testing"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/order"
)
func testApplier(t *testing.T) *orderedApplier {
t.Helper()
reg := actor.NewMutationRegistry()
order.RegisterMutations(reg)
storage := actor.NewDiskStorage[order.OrderGrain](t.TempDir(), reg)
pool, err := actor.NewSimpleGrainPool(actor.GrainPoolConfig[order.OrderGrain]{
Hostname: "test",
Spawn: func(_ context.Context, id uint64) (actor.Grain[order.OrderGrain], error) {
return order.NewOrderGrain(id, time.Now()), nil
},
SpawnHost: func(string) (actor.Host[order.OrderGrain], error) { return nil, fmt.Errorf("none") },
Destroy: func(actor.Grain[order.OrderGrain]) error { return nil },
TTL: time.Hour,
PoolSize: 100,
MutationRegistry: reg,
Storage: nil,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
return &orderedApplier{pool: pool, storage: storage}
}
func TestIngestLegacyOrder(t *testing.T) {
app := testApplier(t)
ctx := context.Background()
body := []byte(`{
"order_id":"K-1","purchase_currency":"SEK","purchase_country":"se","locale":"sv-SE",
"order_amount":15000,"order_tax_amount":3000,
"order_lines":[{"reference":"l1","name":"Widget","quantity":1,"unit_price":15000,"tax_rate":25,"total_amount":15000,"total_tax_amount":3000}]
}`)
if err := ingestLegacyOrder(ctx, app, body); err != nil {
t.Fatalf("ingest: %v", err)
}
id := legacyOrderID("K-1")
g, err := app.Get(ctx, id)
if err != nil {
t.Fatal(err)
}
if g.Status != order.StatusCaptured {
t.Fatalf("status = %q, want captured", g.Status)
}
if g.CapturedAmount != 15000 {
t.Fatalf("captured = %d, want 15000", g.CapturedAmount)
}
// Redelivery must be idempotent — no double capture, no new payment.
if err := ingestLegacyOrder(ctx, app, body); err != nil {
t.Fatalf("re-ingest: %v", err)
}
g2, _ := app.Get(ctx, id)
if g2.CapturedAmount != 15000 {
t.Fatalf("redelivery double-captured: %d", g2.CapturedAmount)
}
if len(g2.Payments) != 1 {
t.Fatalf("redelivery created %d payments, want 1", len(g2.Payments))
}
}
-111
View File
@@ -1,111 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
)
// flowStore holds the saga/flow definitions the service can run. Definitions
// are JSON files in a directory (editable by the backoffice saga editor),
// overlaid on a set of built-in seeds so a fresh deployment always has the
// defaults. Saving validates against the engine before writing.
type flowStore struct {
mu sync.RWMutex
dir string
engine *flow.Engine
defs map[string]*flow.Definition
}
var validFlowName = func(s string) bool {
if s == "" || len(s) > 64 {
return false
}
for _, r := range s {
if !(r == '-' || r == '_' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
return false
}
}
return true
}
// newFlowStore seeds with the built-in definitions, then overlays any JSON files
// found in dir (on-disk wins, so edits persist and take effect).
func newFlowStore(dir string, engine *flow.Engine, seed map[string]*flow.Definition) (*flowStore, error) {
s := &flowStore{dir: dir, engine: engine, defs: map[string]*flow.Definition{}}
for name, def := range seed {
s.defs[name] = def
}
matches, _ := filepath.Glob(filepath.Join(dir, "*.json"))
for _, m := range matches {
data, err := os.ReadFile(m)
if err != nil {
return nil, fmt.Errorf("read flow %s: %w", m, err)
}
def, err := flow.Parse(data)
if err != nil {
return nil, fmt.Errorf("parse flow %s: %w", m, err)
}
if def.Name == "" {
def.Name = strings.TrimSuffix(filepath.Base(m), ".json")
}
s.defs[def.Name] = def
}
return s, nil
}
func (s *flowStore) get(name string) (*flow.Definition, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
d, ok := s.defs[name]
return d, ok
}
func (s *flowStore) list() []*flow.Definition {
s.mu.RLock()
defer s.mu.RUnlock()
names := make([]string, 0, len(s.defs))
for n := range s.defs {
names = append(names, n)
}
sort.Strings(names)
out := make([]*flow.Definition, 0, len(names))
for _, n := range names {
out = append(out, s.defs[n])
}
return out
}
// save validates the definition, persists it to <dir>/<name>.json and updates
// the in-memory map so the next checkout uses it.
func (s *flowStore) save(name string, def *flow.Definition) error {
if !validFlowName(name) {
return fmt.Errorf("invalid flow name %q", name)
}
if def.Name == "" {
def.Name = name
}
if err := s.engine.Validate(def); err != nil {
return err
}
data, err := json.MarshalIndent(def, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(s.dir, 0o755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(s.dir, name+".json"), append(data, '\n'), 0o644); err != nil {
return err
}
s.mu.Lock()
s.defs[name] = def
s.mu.Unlock()
return nil
}
-231
View File
@@ -1,231 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
"git.k6n.net/mats/go-cart-actor/pkg/order"
messages "git.k6n.net/mats/go-cart-actor/proto/order"
"google.golang.org/protobuf/proto"
)
func nowMs() int64 { return time.Now().UnixMilli() }
// applyOne applies a single mutation and surfaces the handler error (the pool
// returns a top-level error only for unregistered mutations; a rejected
// transition is carried per-mutation in the result).
func applyOne(ctx context.Context, app *orderedApplier, id uint64, msg proto.Message) error {
res, err := app.Apply(ctx, id, msg)
if err != nil {
return err
}
if res != nil {
for _, m := range res.Mutations {
if m.Error != nil {
return m.Error
}
}
}
return nil
}
// capturedRef returns the capture reference of the first captured payment, which
// the payment provider needs to issue a refund against.
func capturedRef(g *order.OrderGrain) string {
for _, p := range g.Payments {
if p.Captured > 0 && p.CaptureRef != "" {
return p.CaptureRef
}
}
return ""
}
// loadOrder parses the id and returns the current grain, writing 400/404 itself
// and returning ok=false when the caller should stop.
func (s *server) loadOrder(w http.ResponseWriter, r *http.Request) (order.OrderId, *order.OrderGrain, bool) {
id, ok := order.ParseOrderId(r.PathValue("id"))
if !ok {
writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid order id"))
return 0, nil, false
}
g, err := s.applier.Get(r.Context(), uint64(id))
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return 0, nil, false
}
if g.Status == order.StatusNew {
writeErr(w, http.StatusNotFound, fmt.Errorf("order not found"))
return 0, nil, false
}
return id, g, true
}
// applyAndRespond applies a mutation and returns the updated order. A rejected
// state transition (handler error) maps to 409 Conflict.
func (s *server) applyAndRespond(w http.ResponseWriter, r *http.Request, id order.OrderId, msg proto.Message) {
res, err := s.applier.Apply(r.Context(), uint64(id), msg)
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
for _, m := range res.Mutations {
if m.Error != nil {
writeErr(w, http.StatusConflict, m.Error)
return
}
}
grain, err := s.applier.Get(r.Context(), uint64(id))
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"orderId": id.String(), "order": grain})
}
// --- lifecycle (b) --------------------------------------------------------
type fulfillReq struct {
Carrier string `json:"carrier,omitempty"`
TrackingNumber string `json:"trackingNumber,omitempty"`
TrackingURI string `json:"trackingUri,omitempty"`
Lines []struct {
Reference string `json:"reference"`
Quantity int32 `json:"quantity"`
} `json:"lines"`
}
func (s *server) handleFulfill(w http.ResponseWriter, r *http.Request) {
id, _, ok := s.loadOrder(w, r)
if !ok {
return
}
var req fulfillReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
fid, _ := order.NewOrderId()
msg := &messages.CreateFulfillment{
Id: "f_" + fid.String(),
Carrier: req.Carrier,
TrackingNumber: req.TrackingNumber,
TrackingUri: req.TrackingURI,
AtMs: nowMs(),
}
for _, l := range req.Lines {
msg.Lines = append(msg.Lines, &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity})
}
s.applyAndRespond(w, r, id, msg)
}
func (s *server) handleComplete(w http.ResponseWriter, r *http.Request) {
id, _, ok := s.loadOrder(w, r)
if !ok {
return
}
s.applyAndRespond(w, r, id, &messages.CompleteOrder{AtMs: nowMs()})
}
func (s *server) handleCancel(w http.ResponseWriter, r *http.Request) {
id, _, ok := s.loadOrder(w, r)
if !ok {
return
}
var req struct {
Reason string `json:"reason"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
s.applyAndRespond(w, r, id, &messages.CancelOrder{Reason: req.Reason, AtMs: nowMs()})
}
type returnReq struct {
Reason string `json:"reason,omitempty"`
Lines []struct {
Reference string `json:"reference"`
Quantity int32 `json:"quantity"`
} `json:"lines"`
}
func (s *server) handleReturn(w http.ResponseWriter, r *http.Request) {
id, _, ok := s.loadOrder(w, r)
if !ok {
return
}
var req returnReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
rid, _ := order.NewOrderId()
msg := &messages.RequestReturn{Id: "r_" + rid.String(), Reason: req.Reason, AtMs: nowMs()}
for _, l := range req.Lines {
msg.Lines = append(msg.Lines, &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity})
}
s.applyAndRespond(w, r, id, msg)
}
func (s *server) handleRefund(w http.ResponseWriter, r *http.Request) {
id, g, ok := s.loadOrder(w, r)
if !ok {
return
}
var req struct {
Amount int64 `json:"amount"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
amount := req.Amount
if amount <= 0 {
amount = g.CapturedAmount - g.RefundedAmount // default: full remaining
}
captureRef := capturedRef(g)
ref, err := s.provider.Refund(r.Context(), captureRef, amount)
if err != nil {
writeErr(w, http.StatusBadGateway, fmt.Errorf("refund failed: %w", err))
return
}
s.applyAndRespond(w, r, id, &messages.IssueRefund{
Provider: ref.Provider,
Amount: ref.Amount,
Reference: ref.Reference,
AtMs: nowMs(),
})
}
// --- saga / flow admin (a) ------------------------------------------------
func (s *server) handleCapabilities(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, s.freg.Capabilities())
}
func (s *server) handleListFlows(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, s.flows.list())
}
func (s *server) handleGetFlow(w http.ResponseWriter, r *http.Request) {
def, ok := s.flows.get(r.PathValue("name"))
if !ok {
writeErr(w, http.StatusNotFound, fmt.Errorf("flow not found"))
return
}
writeJSON(w, http.StatusOK, def)
}
func (s *server) handleSaveFlow(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
var def flow.Definition
if err := json.NewDecoder(r.Body).Decode(&def); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
if err := s.flows.save(name, &def); err != nil {
// Validation failures (unknown action/hook/predicate) are client errors.
writeErr(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, &def)
}
-193
View File
@@ -1,193 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
"git.k6n.net/mats/go-cart-actor/pkg/order"
messages "git.k6n.net/mats/go-cart-actor/proto/order"
)
// fromCheckoutReq is the payload the checkout service sends to create an order
// from a settled (paid) checkout. All payment processing has already happened
// via Klarna/Adyen on the checkout grain; the order service records the result
// in its event-sourced grain.
type fromCheckoutReq struct {
CheckoutId string `json:"checkoutId"`
IdempotencyKey string `json:"idempotencyKey"`
Flow string `json:"flow,omitempty"`
CartId string `json:"cartId"`
Currency string `json:"currency"`
Locale string `json:"locale,omitempty"`
Country string `json:"country"`
CustomerEmail string `json:"customerEmail,omitempty"`
CustomerName string `json:"customerName,omitempty"`
Lines []lineReq `json:"lines"`
// Payment carries the externally-settled payment result. The provider has
// already authorized and captured; the order grain records these facts.
Payment struct {
Provider string `json:"provider"` // "klarna" or "adyen"
Reference string `json:"reference"` // processor reference (PSP reference)
Amount int64 `json:"amount"` // minor units
} `json:"payment"`
}
// handleFromCheckout creates an event-sourced order from a settled checkout.
// It runs the place-and-pay flow with a passthrough provider that records the
// already-completed authorization and capture, bypassing any external payment
// call. The response carries the order grain, flow result, and order ID.
//
// Idempotent: a retry with the same idempotencyKey returns the existing order
// (409 Conflict) rather than creating a duplicate.
func (s *server) handleFromCheckout(w http.ResponseWriter, r *http.Request) {
var req fromCheckoutReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, fmt.Errorf("bad body: %w", err))
return
}
if len(req.Lines) == 0 {
writeErr(w, http.StatusBadRequest, fmt.Errorf("from-checkout: at least one line required"))
return
}
// ── Idempotency check ─────────────────────────────────────────────
// Lock the key for the whole check→create→record sequence so a concurrent
// retry with the same key can't race past the check and create a second
// order. The store is durable, so this also holds across a restart.
if req.IdempotencyKey != "" {
unlock := s.idem.Lock(req.IdempotencyKey)
defer unlock()
if existingID, ok := s.idem.Get(req.IdempotencyKey); ok {
// The order already exists — return it.
g, err := s.applier.Get(r.Context(), existingID)
if err != nil {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("idempotent lookup: %w", err))
return
}
writeJSON(w, http.StatusConflict, map[string]any{
"orderId": order.OrderId(existingID).String(),
"order": g,
"existing": true,
})
return
}
}
// ── Create the order grain ────────────────────────────────────────
id, err := order.NewOrderId()
if err != nil {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("new order id: %w", err))
return
}
ordID := uint64(id)
// Build PlaceOrder from the same lineReq format the direct checkout uses.
po := buildFromCheckoutPlaceOrder(id, &req)
// Build a passthrough provider for the externally-settled payment.
provider := order.NewPassthroughProvider(req.Payment.Provider, req.Payment.Reference, req.Payment.Amount)
// Run the place-and-pay flow with the passthrough provider. Since the
// payment is already settled, this records the authorization and capture
// without any external call.
flowName := req.Flow
if flowName == "" {
flowName = s.defaultFlow
}
def, ok := s.flows.get(flowName)
if !ok {
writeErr(w, http.StatusBadRequest, fmt.Errorf("unknown flow %q", flowName))
return
}
// Create a per-request flow registry so the authorize/capture actions use
// the passthrough provider for this specific order. RegisterFlowActions
// registers all order lifecycle actions, overriding the default provider.
freg := flow.NewRegistry()
flow.RegisterBuiltinHooks(freg)
order.RegisterFlowActions(freg, s.applier, provider)
order.RegisterEmitHook(freg, nil)
engine := flow.NewEngine(freg, s.logger)
st := flow.NewState(ordID, s.logger)
st.Vars[order.PlaceOrderVar] = po
res, runErr := engine.Run(r.Context(), def, st)
grain, getErr := s.applier.Get(r.Context(), ordID)
if getErr != nil {
writeErr(w, http.StatusInternalServerError, getErr)
return
}
// On failure the flow should have compensated (voided + cancelled). Return
// the failed order with the flow trace so the checkout service can decide
// how to proceed (e.g. alert operator, retry with a new idempotency key).
status := http.StatusCreated
if runErr != nil {
status = http.StatusPaymentRequired
s.logger.Warn("from-checkout flow failed", "orderId", id.String(), "err", runErr)
}
// Record the idempotency key durably only on success, so retries (incl. after
// a restart) return the captured order. A failed flow (402) is deliberately
// NOT recorded: the failed grain is terminal, and a retry with the same key
// should re-attempt rather than be handed back a dead order as a 409 hit
// (which the checkout client treats as a successful, existing order).
if req.IdempotencyKey != "" && runErr == nil {
if err := s.idem.Put(req.IdempotencyKey, ordID); err != nil {
s.logger.Error("idempotency record failed", "key", req.IdempotencyKey, "orderId", id.String(), "err", err)
}
}
writeJSON(w, status, map[string]any{
"orderId": id.String(),
"flow": res,
"order": grain,
})
}
// buildFromCheckoutPlaceOrder converts a from-checkout request into a PlaceOrder
// proto message, computing per-line and order totals from the lineReq entries.
func buildFromCheckoutPlaceOrder(id order.OrderId, req *fromCheckoutReq) *messages.PlaceOrder {
orderRef := "ord-" + id.String()
po := &messages.PlaceOrder{
OrderReference: orderRef,
CartId: req.CartId,
Currency: req.Currency,
Locale: req.Locale,
Country: req.Country,
CustomerEmail: req.CustomerEmail,
CustomerName: req.CustomerName,
PlacedAtMs: time.Now().UnixMilli(),
}
var total, totalTax int64
for _, l := range req.Lines {
lineTotal := l.UnitPrice * int64(l.Quantity)
var lineTax int64
if l.TaxRate > 0 {
// inc-vat -> tax portion = total * rate / (100 + rate)
lineTax = lineTotal * int64(l.TaxRate) / int64(100+l.TaxRate)
}
total += lineTotal
totalTax += lineTax
po.Lines = append(po.Lines, &messages.OrderLine{
Reference: l.Reference,
Sku: l.Sku,
Name: l.Name,
Quantity: l.Quantity,
UnitPrice: l.UnitPrice,
TaxRate: l.TaxRate,
TotalAmount: lineTotal,
TotalTax: lineTax,
})
}
po.TotalAmount = total
po.TotalTax = totalTax
return po
}
-496
View File
@@ -1,496 +0,0 @@
// Command order is a single-node HTTP entrypoint for the order grain. It exposes
// a simple checkout that creates an order by running the place-and-pay flow
// (place -> mock authorize -> capture) on the actor framework, plus read APIs
// for an order and its event timeline. No clustering, no external payment — the
// simplest path to a real, event-sourced, captured order.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"git.k6n.net/mats/go-cart-actor/internal/ucp"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
"git.k6n.net/mats/go-cart-actor/pkg/idempotency"
"git.k6n.net/mats/go-cart-actor/pkg/order"
"git.k6n.net/mats/go-cart-actor/pkg/outbox"
messages "git.k6n.net/mats/go-cart-actor/proto/order"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
)
type server struct {
pool *actor.SimpleGrainPool[order.OrderGrain]
applier *orderedApplier
storage *actor.DiskStorage[order.OrderGrain]
reg actor.MutationRegistry
engine *flow.Engine
freg *flow.Registry
flows *flowStore
provider order.PaymentProvider
defaultFlow string
dataDir string
idem *idempotency.Store
logger *slog.Logger
}
func main() {
addr := envOr("ORDER_ADDR", ":8092") // :8090 cart, :8091 redirector
dataDir := envOr("ORDER_DATA", "data/orders")
flowsDir := envOr("ORDER_FLOWS", "data/order-flows")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
log.Fatalf("create data dir: %v", err)
}
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
reg := actor.NewMutationRegistry()
order.RegisterMutations(reg)
storage := actor.NewDiskStorage[order.OrderGrain](dataDir, reg)
pool, err := actor.NewSimpleGrainPool(actor.GrainPoolConfig[order.OrderGrain]{
Hostname: "order-1",
Spawn: func(ctx context.Context, id uint64) (actor.Grain[order.OrderGrain], error) {
g := order.NewOrderGrain(id, time.Now())
// Replay any persisted history so the resident grain is current.
if err := storage.LoadEvents(ctx, id, g); err != nil {
return nil, err
}
return g, nil
},
SpawnHost: func(string) (actor.Host[order.OrderGrain], error) {
return nil, fmt.Errorf("order service is single-node")
},
Destroy: func(actor.Grain[order.OrderGrain]) error { return nil },
TTL: time.Hour,
PoolSize: 10000,
// Storage is intentionally nil here: the pool persists mutations in a
// fire-and-forget goroutine, so concurrent appends for one grain can land
// out of order on disk — fatal for an event log, where replay order is the
// state. We persist through orderedApplier instead (synchronous, ordered,
// and only for mutations that actually applied). Replay on spawn still uses
// `storage` directly in Spawn above.
MutationRegistry: reg,
Storage: nil,
})
if err != nil {
log.Fatalf("create pool: %v", err)
}
applier := &orderedApplier{pool: pool, storage: storage}
provider := selectProvider(logger)
// Flow registry: generic hooks + the order lifecycle actions + predicates,
// paying via the selected provider. The amqp_emit hook bridges saga steps to
// the broker (publisher is nil without AMQP — the hook then errors at run
// time, which is logged, not fatal).
amqpURL := os.Getenv("AMQP_URL")
var emitPub order.Publisher
if amqpURL != "" {
// The amqp_emit hook writes to a durable outbox rather than the broker
// directly; a relay goroutine drains it to RabbitMQ (reconnecting), so an
// event survives a broker outage or a crash after the state change.
box, err := outbox.Open(filepath.Join(dataDir, "outbox.log"))
if err != nil {
// Durability-critical (same as the idempotency store): fail fast rather
// than silently publish events nowhere for the whole process lifetime.
log.Fatalf("open outbox: %v", err)
}
go box.Run(context.Background(), newRabbitPublisher(amqpURL), 5*time.Second, logger)
emitPub = box
}
freg := flow.NewRegistry()
flow.RegisterBuiltinHooks(freg)
order.RegisterFlowActions(freg, applier, provider)
order.RegisterEmitHook(freg, emitPub)
engine := flow.NewEngine(freg, logger)
def, err := order.EmbeddedFlow("place-and-pay")
if err != nil {
log.Fatalf("load flow: %v", err)
}
flows, err := newFlowStore(flowsDir, engine, map[string]*flow.Definition{def.Name: def})
if err != nil {
log.Fatalf("load flows: %v", err)
}
// Durable idempotency store for order creation — survives a restart, so a
// client retry after a crash returns the existing order instead of creating
// a duplicate.
idem, err := idempotency.Open(filepath.Join(dataDir, "idempotency.log"))
if err != nil {
log.Fatalf("open idempotency store: %v", err)
}
s := &server{
pool: pool, applier: applier, storage: storage, reg: reg,
engine: engine, freg: freg, flows: flows, provider: provider,
defaultFlow: def.Name, dataDir: dataDir, idem: idem, logger: logger,
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
// Checkout + order reads.
mux.HandleFunc("POST /checkout", s.handleCheckout)
mux.HandleFunc("POST /api/orders/from-checkout", s.handleFromCheckout)
mux.HandleFunc("GET /api/orders", s.handleList)
mux.HandleFunc("GET /api/orders/{id}", s.handleGet)
mux.HandleFunc("GET /api/orders/{id}/events", s.handleEvents)
// Post-purchase lifecycle (the grain already supports these transitions).
mux.HandleFunc("POST /api/orders/{id}/fulfillments", s.handleFulfill)
mux.HandleFunc("POST /api/orders/{id}/complete", s.handleComplete)
mux.HandleFunc("POST /api/orders/{id}/cancel", s.handleCancel)
mux.HandleFunc("POST /api/orders/{id}/returns", s.handleReturn)
mux.HandleFunc("POST /api/orders/{id}/refunds", s.handleRefund)
// UCP REST adapter — Universal Commerce Protocol order lifecycle.
orderUCP := ucp.OrderHandler(s.applier)
if signer := loadUCPOrderSigner(); signer != nil {
orderUCP = ucp.WithSigning(orderUCP, signer)
logger.Info("ucp signing enabled")
}
// StripPrefix is required because the sub-mux (OrderHandler) registers
// routes like "GET /{id}" which expect a relative path; Go's ServeMux
// passes the full request path without stripping the prefix.
// Only the subtree pattern is registered (see comment in cmd/cart/main.go
// for why the exact-match pattern is omitted).
mux.Handle("/ucp/v1/orders/", http.StripPrefix("/ucp/v1/orders", orderUCP))
// Saga / flow admin (backoffice editor).
mux.HandleFunc("GET /sagas/capabilities", s.handleCapabilities)
mux.HandleFunc("GET /sagas/flows", s.handleListFlows)
mux.HandleFunc("GET /sagas/flows/{name}", s.handleGetFlow)
mux.HandleFunc("PUT /sagas/flows/{name}", s.handleSaveFlow)
// Ingest legacy checkout orders (Klarna/Adyen → order-queue) so this service
// is the single source of orders, superseding go-order-manager.
if amqpURL != "" {
startLegacyIngest(context.Background(), amqpURL, applier, logger)
}
logger.Info("order service listening", "addr", addr, "data", dataDir, "flows", flowsDir)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("serve: %v", err)
}
}
// orderedApplier wraps the grain pool to persist the event log correctly: it
// appends each applied mutation synchronously and in call order (the pool's own
// storage path is async/unordered), and only persists mutations whose handler
// succeeded — a rejected transition must never enter the log. A single mutex
// serialises all appends, which is ample for a single-node checkout service.
type orderedApplier struct {
pool *actor.SimpleGrainPool[order.OrderGrain]
storage *actor.DiskStorage[order.OrderGrain]
mu sync.Mutex
}
func (a *orderedApplier) Get(ctx context.Context, id uint64) (*order.OrderGrain, error) {
return a.pool.Get(ctx, id)
}
func (a *orderedApplier) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[order.OrderGrain], error) {
res, err := a.pool.Apply(ctx, id, mutation...)
if err != nil || res == nil {
return res, err
}
// Persist only the mutations that actually applied (result index aligns with
// the input order).
applied := make([]proto.Message, 0, len(mutation))
for i, m := range mutation {
if i < len(res.Mutations) && res.Mutations[i].Error != nil {
continue
}
applied = append(applied, m)
}
if len(applied) > 0 {
a.mu.Lock()
appendErr := a.storage.AppendMutations(id, applied...)
a.mu.Unlock()
if appendErr != nil {
return res, appendErr
}
}
return res, nil
}
// --- checkout -------------------------------------------------------------
type lineReq struct {
Reference string `json:"reference"`
Sku string `json:"sku"`
Name string `json:"name"`
Quantity int32 `json:"quantity"`
UnitPrice int64 `json:"unitPrice"` // inc-vat, minor units
TaxRate int32 `json:"taxRate"` // percent
}
type checkoutReq struct {
OrderReference string `json:"orderReference,omitempty"`
CartId string `json:"cartId,omitempty"`
Currency string `json:"currency,omitempty"`
Locale string `json:"locale,omitempty"`
Country string `json:"country,omitempty"`
CustomerEmail string `json:"customerEmail,omitempty"`
CustomerName string `json:"customerName,omitempty"`
BillingAddress json.RawMessage `json:"billingAddress,omitempty"`
ShippingAddress json.RawMessage `json:"shippingAddress,omitempty"`
Lines []lineReq `json:"lines"`
}
func (s *server) handleCheckout(w http.ResponseWriter, r *http.Request) {
var req checkoutReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, fmt.Errorf("bad body: %w", err))
return
}
if len(req.Lines) == 0 {
writeErr(w, http.StatusBadRequest, fmt.Errorf("checkout requires at least one line"))
return
}
id, err := order.NewOrderId()
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
if req.OrderReference == "" {
req.OrderReference = "ord-" + id.String()
}
if req.Currency == "" {
req.Currency = "SEK"
}
flowName := r.URL.Query().Get("flow")
if flowName == "" {
flowName = s.defaultFlow
}
def, ok := s.flows.get(flowName)
if !ok {
writeErr(w, http.StatusBadRequest, fmt.Errorf("unknown flow %q", flowName))
return
}
po := buildPlaceOrder(id, &req)
st := flow.NewState(uint64(id), s.logger)
st.Vars[order.PlaceOrderVar] = po
res, runErr := s.engine.Run(r.Context(), def, st)
grain, getErr := s.pool.Get(r.Context(), uint64(id))
if getErr != nil {
writeErr(w, http.StatusInternalServerError, getErr)
return
}
status := http.StatusCreated
if runErr != nil {
// The flow compensated; the order exists but is not paid. Report 402 with
// the (now cancelled/failed) order and the flow trace so the caller knows.
status = http.StatusPaymentRequired
s.logger.Warn("checkout flow failed", "orderId", id.String(), "err", runErr)
}
writeJSON(w, status, map[string]any{
"orderId": id.String(),
"flow": res,
"order": grain,
})
}
// buildPlaceOrder converts a checkout request into the PlaceOrder event,
// computing per-line and order totals (inc-vat) from the lines.
func buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.PlaceOrder {
po := &messages.PlaceOrder{
OrderReference: req.OrderReference,
CartId: req.CartId,
Currency: req.Currency,
Locale: req.Locale,
Country: req.Country,
CustomerEmail: req.CustomerEmail,
CustomerName: req.CustomerName,
BillingAddress: req.BillingAddress,
ShippingAddress: req.ShippingAddress,
PlacedAtMs: time.Now().UnixMilli(),
}
var total, totalTax int64
for _, l := range req.Lines {
lineTotal := l.UnitPrice * int64(l.Quantity)
var lineTax int64
if l.TaxRate > 0 {
// inc-vat -> tax portion = total * rate / (100 + rate)
lineTax = lineTotal * int64(l.TaxRate) / int64(100+l.TaxRate)
}
total += lineTotal
totalTax += lineTax
po.Lines = append(po.Lines, &messages.OrderLine{
Reference: l.Reference,
Sku: l.Sku,
Name: l.Name,
Quantity: l.Quantity,
UnitPrice: l.UnitPrice,
TaxRate: l.TaxRate,
TotalAmount: lineTotal,
TotalTax: lineTax,
})
}
po.TotalAmount = total
po.TotalTax = totalTax
return po
}
// --- reads ----------------------------------------------------------------
func (s *server) handleGet(w http.ResponseWriter, r *http.Request) {
id, ok := order.ParseOrderId(r.PathValue("id"))
if !ok {
writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid order id"))
return
}
grain, err := s.pool.Get(r.Context(), uint64(id))
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
if grain.Status == order.StatusNew {
writeErr(w, http.StatusNotFound, fmt.Errorf("order not found"))
return
}
writeJSON(w, http.StatusOK, map[string]any{"orderId": id.String(), "order": grain})
}
type eventView struct {
Type string `json:"type"`
Time string `json:"time,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
}
// handleEvents returns the raw event timeline by reading the grain's append-only
// log (the source of truth) without re-applying it to live state.
func (s *server) handleEvents(w http.ResponseWriter, r *http.Request) {
id, ok := order.ParseOrderId(r.PathValue("id"))
if !ok {
writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid order id"))
return
}
var events []eventView
throwaway := order.NewOrderGrain(uint64(id), time.Now())
err := s.storage.LoadEventsFunc(r.Context(), uint64(id), throwaway,
func(msg proto.Message, _ int, ts time.Time) bool {
name, _ := s.reg.GetTypeName(msg)
payload, _ := protojson.Marshal(msg)
events = append(events, eventView{Type: name, Time: ts.UTC().Format(time.RFC3339), Payload: payload})
return false // collect only; do not apply
})
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"orderId": id.String(), "events": events})
}
type orderSummary struct {
OrderId string `json:"orderId"`
Reference string `json:"reference,omitempty"`
Status order.Status `json:"status"`
TotalAmount int64 `json:"totalAmount"`
CapturedAmount int64 `json:"capturedAmount"`
Currency string `json:"currency,omitempty"`
PlacedAt string `json:"placedAt,omitempty"`
}
// handleList scans the data dir for order logs and summarises each (replaying it
// through the pool). Fine for a single-node simple checkout; a production list
// would use an index/projection store.
func (s *server) handleList(w http.ResponseWriter, r *http.Request) {
matches, _ := filepath.Glob(filepath.Join(s.dataDir, "*.events.log"))
out := make([]orderSummary, 0, len(matches))
for _, m := range matches {
base := strings.TrimSuffix(filepath.Base(m), ".events.log")
raw, err := strconv.ParseUint(base, 10, 64)
if err != nil {
continue
}
g, err := s.pool.Get(r.Context(), raw)
if err != nil || g.Status == order.StatusNew {
continue
}
out = append(out, orderSummary{
OrderId: order.OrderId(raw).String(),
Reference: g.OrderReference,
Status: g.Status,
TotalAmount: g.TotalAmount,
CapturedAmount: g.CapturedAmount,
Currency: g.Currency,
PlacedAt: g.PlacedAt,
})
}
writeJSON(w, http.StatusOK, out)
}
// --- helpers --------------------------------------------------------------
// selectProvider picks the payment provider from the environment. Default is
// the dependency-free mock; set PAYMENT_PROVIDER=stripe + STRIPE_SECRET_KEY for
// real Stripe (STRIPE_API_BASE / STRIPE_PAYMENT_METHOD optionally override).
func selectProvider(logger *slog.Logger) order.PaymentProvider {
if os.Getenv("PAYMENT_PROVIDER") == "stripe" {
key := os.Getenv("STRIPE_SECRET_KEY")
if key == "" {
logger.Warn("PAYMENT_PROVIDER=stripe but STRIPE_SECRET_KEY is empty; falling back to mock")
return order.NewMockProvider()
}
logger.Info("using stripe payment provider")
return order.NewStripeProvider(key, os.Getenv("STRIPE_API_BASE"), os.Getenv("STRIPE_PAYMENT_METHOD"))
}
return order.NewMockProvider()
}
// loadUCPOrderSigner loads the UCP ECDSA signing key from the path specified in
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
func loadUCPOrderSigner() *ucp.SigningConfig {
path := os.Getenv("UCP_SIGNING_KEY_PATH")
if path == "" {
return nil
}
pemData, err := os.ReadFile(path)
if err != nil {
log.Printf("ucp signing: cannot read key %s: %v", path, err)
return nil
}
cfg, err := ucp.NewSigningConfigFromPEM(pemData, "k6n-ecdsa-2026")
if err != nil {
log.Printf("ucp signing: invalid key at %s: %v", path, err)
return nil
}
return cfg
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, status int, err error) {
writeJSON(w, status, map[string]string{"error": err.Error()})
}
-188
View File
@@ -1,188 +0,0 @@
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"time"
"git.k6n.net/mats/go-cart-actor/internal/customerauth"
"git.k6n.net/mats/go-cart-actor/internal/ucp"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/profile"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
// authSecret returns the HMAC key for customer session cookies. It reads
// CUSTOMER_AUTH_SECRET; when unset it generates an ephemeral random key and
// warns that issued sessions will not survive a restart (fine for dev).
func authSecret() []byte {
if v := os.Getenv("CUSTOMER_AUTH_SECRET"); v != "" {
return []byte(v)
}
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
log.Fatalf("could not generate auth secret: %v", err)
}
log.Print("warning: CUSTOMER_AUTH_SECRET unset — using an ephemeral key; customer sessions will not survive a restart")
return []byte(hex.EncodeToString(b))
}
var podIp = os.Getenv("POD_IP")
var name = os.Getenv("POD_NAME")
// loadUCPProfileSigner loads the UCP ECDSA signing key from the path specified in
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
func loadUCPProfileSigner() *ucp.SigningConfig {
path := os.Getenv("UCP_SIGNING_KEY_PATH")
if path == "" {
return nil
}
pemData, err := os.ReadFile(path)
if err != nil {
log.Printf("ucp signing: cannot read key %s: %v", path, err)
return nil
}
cfg, err := ucp.NewSigningConfigFromPEM(pemData, "k6n-ecdsa-2026")
if err != nil {
log.Printf("ucp signing: invalid key at %s: %v", path, err)
return nil
}
return cfg
}
// getEnv returns the value of the environment variable named by key, or def if unset/empty.
func getEnv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func main() {
reg := profile.NewProfileMutationRegistry()
profileDir := getEnv("PROFILE_DIR", "data/profiles")
if err := os.MkdirAll(profileDir, 0755); err != nil {
log.Printf("warning: could not create profile data directory %s: %v", profileDir, err)
}
diskStorage := actor.NewDiskStorage[profile.ProfileGrain](profileDir, reg)
poolConfig := actor.GrainPoolConfig[profile.ProfileGrain]{
MutationRegistry: reg,
Storage: diskStorage,
Spawn: func(ctx context.Context, id uint64) (actor.Grain[profile.ProfileGrain], error) {
ret := profile.NewProfileGrain(id, time.Now())
err := diskStorage.LoadEvents(ctx, id, ret)
return ret, err
},
// Destroy is an optional grain-eviction cleanup hook; profiles need none
// (disk storage persists via its own loop), so it's left unset — purge()
// skips a nil Destroy.
TTL: 10 * time.Minute,
PoolSize: 65535,
Hostname: podIp,
}
pool, err := actor.NewSimpleGrainPool(poolConfig)
if err != nil {
log.Fatalf("Error creating profile pool: %v\n", err)
}
mux := http.NewServeMux()
debugMux := http.NewServeMux()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
otelShutdown, err := setupOTelSDK(ctx)
if err != nil {
log.Fatalf("Unable to start otel %v", err)
}
// UCP Customer REST adapter
customerUCP := ucp.CustomerHandler(pool)
if signer := loadUCPProfileSigner(); signer != nil {
customerUCP = ucp.WithSigning(customerUCP, signer)
log.Print("ucp customer signing enabled")
}
// StripPrefix is required because the sub-mux (CustomerHandler) registers
// routes like "GET /{id}" which expect a relative path; Go's ServeMux
// passes the full request path without stripping the prefix.
mux.Handle("/ucp/v1/customers/", http.StripPrefix("/ucp/v1/customers", customerUCP))
// Customer auth: password signup/login + session cookies + identity linking.
credStore, err := customerauth.LoadCredentialStore(filepath.Join(profileDir, "credentials.json"))
if err != nil {
log.Fatalf("Error loading credential store: %v\n", err)
}
authHandler := customerauth.NewServer(credStore, pool, customerauth.NewSigner(authSecret()), 0).Handler()
mux.Handle("/ucp/v1/auth/", http.StripPrefix("/ucp/v1/auth", authHandler))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
grainCount, capacity := pool.LocalUsage()
if grainCount >= capacity {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("grain pool at capacity"))
return
}
if !pool.IsHealthy() {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("pool not healthy"))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("1.0.0"))
})
srv := &http.Server{
Addr: getEnv("PROFILE_ADDR", ":8080"),
BaseContext: func(net.Listener) context.Context { return ctx },
ReadTimeout: 10 * time.Second,
WriteTimeout: 20 * time.Second,
Handler: otelhttp.NewHandler(mux, "/"),
}
defer func() {
fmt.Println("Shutting down profile service")
otelShutdown(context.Background())
diskStorage.Close()
pool.Close()
}()
srvErr := make(chan error, 1)
go func() {
srvErr <- srv.ListenAndServe()
}()
log.Print("Profile server started at port 8080")
go http.ListenAndServe(":8081", debugMux)
select {
case err = <-srvErr:
log.Fatalf("Unable to start server: %v", err)
case <-ctx.Done():
stop()
}
}
-108
View File
@@ -1,108 +0,0 @@
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 := func(ctx context.Context) error {
var err error
for _, fn := range shutdownFuncs {
err = errors.Join(err, fn(ctx))
}
shutdownFuncs = nil
return err
}
handleErr := func(inErr error) {
err = errors.Join(inErr, shutdown(ctx))
}
prop := newPropagator()
otel.SetTextMapPropagator(prop)
tracerProvider, err := newTracerProvider()
if err != nil {
handleErr(err)
return shutdown, err
}
shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown)
otel.SetTracerProvider(tracerProvider)
meterProvider, err := newMeterProvider()
if err != nil {
handleErr(err)
return shutdown, err
}
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
otel.SetMeterProvider(meterProvider)
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,
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
}
-37
View File
@@ -1,37 +0,0 @@
{"type":"AddItem","timestamp":"2026-06-14T12:06:33.026264929+02:00","mutation":{"item_id":147528,"quantity":1,"price":18559,"orgPrice":23199,"sku":"A0161291","name":"SF vridfönster 2380x680mm 2-luft, insida trä utsida aluminium, 3-glas ","image":"/A0161291.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6NywiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIyIiwibHVmdF90aXRsZSI6IjItTHVmdCAodHbDpSDDtnBwbmluZ3NiYXJhIGLDpWdhciBpIHNhbW1hIGthcm0pIiwibW9kZWwiOiIxMzciLCJtb2RlbERlc2NyaXB0aW9uIjoiU1AgQmFsYW5zIGbDtm5zdGVyIHZyaWQiLCJwYWdlSWQiOjg4NDM2LCJwb3N0U2l6ZSI6NzEsInByZXZpb3VzRGlzY291bnQiOjMwLCJzcHJvanNTaXplIjozMSwic3RvcmxlayI6IjI0MDciLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoIjoyNCwid2lkdGhNYXJnaW4iOjIwfQ=="}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T12:06:41.00641672+02:00","mutation":{"Id":1,"quantity":2}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T12:06:50.402587934+02:00","mutation":{"Id":1,"quantity":1}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T12:08:25.714006052+02:00","mutation":{"Id":1,"quantity":2}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T12:11:38.722916072+02:00","mutation":{"Id":1,"quantity":1}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T12:14:12.485362998+02:00","mutation":{"Id":1,"quantity":2}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T12:14:13.259209602+02:00","mutation":{"Id":1,"quantity":1}}
{"type":"AddItem","timestamp":"2026-06-14T12:14:19.560413353+02:00","mutation":{"item_id":146107,"quantity":1,"price":1812900,"orgPrice":2266125,"sku":"A0158995","name":"SF vridfönster 1680x1380mm 1-luft, insida trä utsida aluminium, 3-glas ","image":"/A0158995.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6MTQsImhlaWdodE1hcmdpbiI6MjAsImlzQWNjZXNzb3J5IjowLCJsdWZ0IjoiMSIsImx1ZnRfdGl0bGUiOiIxLUx1ZnQgKGVuIMO2cHBuaW5nc2JhciBiw6VnZSBpIHNhbW1hIGthcm0pIiwibW9kZWwiOiIxMzciLCJtb2RlbERlc2NyaXB0aW9uIjoiU1AgQmFsYW5zIGbDtm5zdGVyIHZyaWQiLCJwYWdlSWQiOjg4NDM2LCJwb3N0U2l6ZSI6NzEsInByZXZpb3VzRGlzY291bnQiOjMwLCJzcHJvanNTaXplIjozMSwic3RvcmxlayI6IjE3MTQiLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoIjoxNywid2lkdGhNYXJnaW4iOjIwfQ=="}}
{"type":"RemoveItem","timestamp":"2026-06-14T12:16:35.832642257+02:00","mutation":{"Id":1}}
{"type":"AddItem","timestamp":"2026-06-14T12:47:41.110158952+02:00","mutation":{"item_id":144047,"quantity":1,"price":690193,"orgPrice":862741,"sku":"A0162590","name":"SF vridfönster 480x580mm 1-luft, insida trä utsida aluminium, 3-glas ","image":"/A0162590.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6NiwiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIxIiwibHVmdF90aXRsZSI6IjEtTHVmdCAoZW4gw7ZwcG5pbmdzYmFyIGLDpWdlIGkgc2FtbWEga2FybSkiLCJtb2RlbCI6IjEzNyIsIm1vZGVsRGVzY3JpcHRpb24iOiJTUCBCYWxhbnMgZsO2bnN0ZXIgdnJpZCIsInBhZ2VJZCI6ODg0MzYsInBvc3RTaXplIjo3MSwicHJldmlvdXNEaXNjb3VudCI6MzAsInNwcm9qc1NpemUiOjMxLCJzdG9ybGVrIjoiMDUwNiIsInRheG9ub215IjpbXSwidW5pdCI6InN0Iiwid2lkdGgiOjUsIndpZHRoTWFyZ2luIjoyMH0="}}
{"type":"AddItem","timestamp":"2026-06-14T12:49:14.15670005+02:00","mutation":{"item_id":144047,"quantity":1,"price":690193,"orgPrice":862741,"sku":"A0162590","name":"SF vridfönster 480x580mm 1-luft, insida trä utsida aluminium, 3-glas ","image":"/A0162590.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6NiwiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIxIiwibHVmdF90aXRsZSI6IjEtTHVmdCAoZW4gw7ZwcG5pbmdzYmFyIGLDpWdlIGkgc2FtbWEga2FybSkiLCJtb2RlbCI6IjEzNyIsIm1vZGVsRGVzY3JpcHRpb24iOiJTUCBCYWxhbnMgZsO2bnN0ZXIgdnJpZCIsInBhZ2VJZCI6ODg0MzYsInBvc3RTaXplIjo3MSwicHJldmlvdXNEaXNjb3VudCI6MzAsInNwcm9qc1NpemUiOjMxLCJzdG9ybGVrIjoiMDUwNiIsInRheG9ub215IjpbXSwidW5pdCI6InN0Iiwid2lkdGgiOjUsIndpZHRoTWFyZ2luIjoyMH0="}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T12:49:19.389741453+02:00","mutation":{"Id":3,"quantity":1}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T12:49:19.68037225+02:00","mutation":{"Id":3}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T12:49:20.250569359+02:00","mutation":{"Id":2}}
{"type":"AddItem","timestamp":"2026-06-14T12:49:22.994875367+02:00","mutation":{"item_id":144047,"quantity":1,"price":690193,"orgPrice":862741,"sku":"A0162590","name":"SF vridfönster 480x580mm 1-luft, insida trä utsida aluminium, 3-glas ","image":"/A0162590.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6NiwiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIxIiwibHVmdF90aXRsZSI6IjEtTHVmdCAoZW4gw7ZwcG5pbmdzYmFyIGLDpWdlIGkgc2FtbWEga2FybSkiLCJtb2RlbCI6IjEzNyIsIm1vZGVsRGVzY3JpcHRpb24iOiJTUCBCYWxhbnMgZsO2bnN0ZXIgdnJpZCIsInBhZ2VJZCI6ODg0MzYsInBvc3RTaXplIjo3MSwicHJldmlvdXNEaXNjb3VudCI6MzAsInNwcm9qc1NpemUiOjMxLCJzdG9ybGVrIjoiMDUwNiIsInRheG9ub215IjpbXSwidW5pdCI6InN0Iiwid2lkdGgiOjUsIndpZHRoTWFyZ2luIjoyMH0="}}
{"type":"AddItem","timestamp":"2026-06-14T13:02:45.511064864+02:00","mutation":{"item_id":146248,"quantity":1,"price":1842370,"orgPrice":2302963,"sku":"A0159038","name":"SF vridfönster 1780x780mm 2-luft, insida trä utsida aluminium, 3-glas ","image":"/A0159038.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6OCwiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIyIiwibHVmdF90aXRsZSI6IjItTHVmdCAodHbDpSDDtnBwbmluZ3NiYXJhIGLDpWdhciBpIHNhbW1hIGthcm0pIiwibW9kZWwiOiIxMzciLCJtb2RlbERlc2NyaXB0aW9uIjoiU1AgQmFsYW5zIGbDtm5zdGVyIHZyaWQiLCJwYWdlSWQiOjg4NDM2LCJwb3N0U2l6ZSI6NzEsInByZXZpb3VzRGlzY291bnQiOjMwLCJzcHJvanNTaXplIjozMSwic3RvcmxlayI6IjE4MDgiLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoIjoxOCwid2lkdGhNYXJnaW4iOjIwfQ=="}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T13:02:48.965993343+02:00","mutation":{"Id":5}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T13:02:49.533960677+02:00","mutation":{"Id":4}}
{"type":"AddItem","timestamp":"2026-06-14T13:03:02.543932484+02:00","mutation":{"item_id":146620,"quantity":1,"price":3043620,"orgPrice":3804525,"sku":"A0164975","name":"SF vridfönster 1880x1180mm 3-luft, insida trä utsida aluminium, 3-glas ","image":"/A0164975.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6MTIsImhlaWdodE1hcmdpbiI6MjAsImlzQWNjZXNzb3J5IjowLCJsdWZ0IjoiMyIsImx1ZnRfdGl0bGUiOiIzLUx1ZnQgKHRyZSDDtnBwbmluZ3NiYXJhIGLDpWdhciBpIHNhbW1hIGthcm0pIiwibW9kZWwiOiIxMzciLCJtb2RlbERlc2NyaXB0aW9uIjoiU1AgQmFsYW5zIGbDtm5zdGVyIHZyaWQiLCJwYWdlSWQiOjg4NDM2LCJwb3N0U2l6ZSI6NzEsInByZXZpb3VzRGlzY291bnQiOjMwLCJzcHJvanNTaXplIjozMSwic3RvcmxlayI6IjE5MTIiLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoIjoxOSwid2lkdGhNYXJnaW4iOjIwfQ=="}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T13:06:48.388062792+02:00","mutation":{"Id":6}}
{"type":"AddItem","timestamp":"2026-06-14T13:07:01.83789281+02:00","mutation":{"item_id":146387,"quantity":1,"price":1767450,"orgPrice":2524929,"sku":"A0164933","name":"SF vridfönster 1780x1180mm 1-luft, insida trä utsida aluminium, 3-glas ","image":"/A0164933.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6MTIsImhlaWdodE1hcmdpbiI6MjAsImlzQWNjZXNzb3J5IjowLCJsdWZ0IjoiMSIsImx1ZnRfdGl0bGUiOiIxLUx1ZnQgKGVuIMO2cHBuaW5nc2JhciBiw6VnZSBpIHNhbW1hIGthcm0pIiwibW9kZWwiOiIxMzciLCJtb2RlbERlc2NyaXB0aW9uIjoiU1AgQmFsYW5zIGbDtm5zdGVyIHZyaWQiLCJwYWdlSWQiOjg4NDM2LCJwb3N0U2l6ZSI6NzEsInByZXZpb3VzRGlzY291bnQiOjMwLCJzcHJvanNTaXplIjozMSwic3RvcmxlayI6IjE4MTIiLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoIjoxOCwid2lkdGhNYXJnaW4iOjIwfQ=="}}
{"type":"AddItem","timestamp":"2026-06-14T13:07:01.837946101+02:00","mutation":{"item_id":170852,"quantity":1,"sku":"A0190103","name":"Rundad profil på karm \u0026 båge, SP utseende","image":"/UserFiles/Svenska_Fonster/Diverse/Genomskarning_rund_profil.jpg","tax":2500,"sellerId":"7","sellerName":"Kaski","parentId":7,"extra_json":"eyJidWxreSI6MCwiY2F0ZWdvcnkiOjE3NCwiZGVsZXRlIjpmYWxzZSwiZGVsaXZlcnlTdHJpbmciOiJOb3JtYWx0IDQtNiBhcmJldHN2ZWNrb3IiLCJkZWxpdmVyeVdlZWsiOjEwLCJkZXNjcmlwdGlvbiI6IiIsImhlaWdodE1hcmdpbiI6MTAsImlzQWNjZXNzb3J5Ijp0cnVlLCJtb2RlbCI6IllEIiwibW9kZWxEZXNjcmlwdGlvbiI6Ikthc2tpIFl0dGVyZMO2cnJhciB0aGVybW8iLCJwYWdlSWQiOjAsInByZXZpb3VzRGlzY291bnQiOjIwLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoTWFyZ2luIjoxMH0="}}
{"type":"AddItem","timestamp":"2026-06-14T13:07:01.837975176+02:00","mutation":{"item_id":123075,"quantity":1,"price":995278,"sku":"A0125821","name":"Ställkostnad egen kulör Ncs/Ral nr (ej lasyr) / leverans","image":"/UserFiles/Fargkarta.jpg","tax":2500,"sellerId":"0","parentId":7,"extra_json":"eyJhY2Nlc3NvcnlNYXRjaCI6IiIsImJ1bGt5IjowLCJjYXRlZ29yeSI6NjUsImRlbGV0ZSI6ZmFsc2UsImRlc2NyaXB0aW9uIjoiIiwiZ3JvdXAiOiIiLCJpc0FjY2Vzc29yeSI6dHJ1ZSwibW9kZWwiOiIiLCJwYWdlSWQiOjAsInRheG9ub215IjpbXSwidW5pdCI6InN0In0="}}
{"type":"AddItem","timestamp":"2026-06-14T13:07:01.837991607+02:00","mutation":{"item_id":123076,"quantity":1,"price":2420510,"sku":"A0125831","name":"Ställkostnad egen kulör utsida NCS S / leverans (väljs på en produkt)","image":"/UserFiles/Fargkarta.jpg","tax":2500,"sellerId":"0","parentId":7,"extra_json":"eyJhY2Nlc3NvcnlNYXRjaCI6IiIsImJ1bGt5IjowLCJjYXRlZ29yeSI6NjYsImRlbGV0ZSI6ZmFsc2UsImRlc2NyaXB0aW9uIjoiIiwiZ3JvdXAiOiIiLCJpc0FjY2Vzc29yeSI6dHJ1ZSwibW9kZWwiOiIiLCJwYWdlSWQiOjAsInRheG9ub215IjpbXSwidW5pdCI6InN0In0="}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T13:28:50.254122718+02:00","mutation":{"Id":7}}
{"type":"AddItem","timestamp":"2026-06-14T15:15:53.256709127+02:00","mutation":{"item_id":148722,"quantity":1,"price":2531850,"orgPrice":3164813,"sku":"A0166079","name":"SF vridfönster 1980x1580mm 1-luft, insida trä utsida aluminium, 2-glas pga storleken","image":"/A0166079.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJhY2Nlc3NvcnlNYXRjaCI6IiIsImJvcmRlckhlaWdodCI6MTk0LCJib3JkZXJXaWR0aCI6MTk0LCJidWxreSI6MSwiY2F0ZWdvcnkiOjAsImRlbGV0ZSI6ZmFsc2UsImRlbGl2ZXJ5U3RyaW5nIjoiTm9ybWFsdCA0LTYgYXJiZXRzdmVja29yIiwiZGVsaXZlcnlXZWVrIjoxMCwiZGVzY3JpcHRpb24iOiJLYXJteXR0ZXJtw6V0dCBicmVkZCB4IGjDtmpkLiIsImRpdmlkZXJTaXplIjoxNjQsImdsYXNzTWFyZ2luUG9zdCI6NzEsImdyb3VwIjoiMTM3IiwiaGVpZ2h0IjoxNiwiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIxIiwibHVmdF90aXRsZSI6IjEtTHVmdCAoZW4gw7ZwcG5pbmdzYmFyIGLDpWdlIGkgc2FtbWEga2FybSkiLCJtb2RlbCI6IjEzNyIsIm1vZGVsRGVzY3JpcHRpb24iOiJTUCBCYWxhbnMgZsO2bnN0ZXIgdnJpZCIsInBhZ2VJZCI6ODg0MzYsInBvc3RTaXplIjo3MSwicHJldmlvdXNEaXNjb3VudCI6MzAsInNwcm9qc1NpemUiOjMxLCJzdG9ybGVrIjoiMjAxNiIsInRheG9ub215IjpbXSwidW5pdCI6InN0Iiwid2lkdGgiOjIwLCJ3aWR0aE1hcmdpbiI6MjB9"}}
{"type":"AddItem","timestamp":"2026-06-14T15:15:53.256762929+02:00","mutation":{"item_id":170852,"quantity":1,"sku":"A0190103","name":"Rundad profil på karm \u0026 båge, SP utseende","image":"/UserFiles/Svenska_Fonster/Diverse/Genomskarning_rund_profil.jpg","tax":2500,"sellerId":"7","sellerName":"Kaski","parentId":11,"extra_json":"eyJidWxreSI6MCwiY2F0ZWdvcnkiOjE3NCwiZGVsZXRlIjpmYWxzZSwiZGVsaXZlcnlTdHJpbmciOiJOb3JtYWx0IDQtNiBhcmJldHN2ZWNrb3IiLCJkZWxpdmVyeVdlZWsiOjEwLCJkZXNjcmlwdGlvbiI6IiIsImhlaWdodE1hcmdpbiI6MTAsImlzQWNjZXNzb3J5Ijp0cnVlLCJtb2RlbCI6IllEIiwibW9kZWxEZXNjcmlwdGlvbiI6Ikthc2tpIFl0dGVyZMO2cnJhciB0aGVybW8iLCJwYWdlSWQiOjAsInByZXZpb3VzRGlzY291bnQiOjIwLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoTWFyZ2luIjoxMH0="}}
{"type":"AddItem","timestamp":"2026-06-14T15:15:53.256793727+02:00","mutation":{"item_id":97103,"quantity":1,"price":4139744,"sku":"A0086072","name":"Brand klassat glas EI30 (välj även förankring)","image":"/UserFiles/Svenska_Fonster/Glas/Brand_Ei30.jpg","tax":2500,"sellerId":"0","parentId":11,"extra_json":"eyJhY2Nlc3NvcnlNYXRjaCI6IiIsImJ1bGt5IjowLCJjYXRlZ29yeSI6MjAsImRlbGV0ZSI6ZmFsc2UsImRlc2NyaXB0aW9uIjoiRXR0IGdsYXMgc29tIGtsYXJhciBrcmF2ZW4gRUkzMCIsImdyb3VwIjoiIiwiaXNBY2Nlc3NvcnkiOnRydWUsIm1vZGVsIjoiIiwicGFnZUlkIjowLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCJ9"}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T15:16:03.078662993+02:00","mutation":{"Id":8,"quantity":1}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T15:16:03.54547595+02:00","mutation":{"Id":8}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T15:16:04.240661903+02:00","mutation":{"Id":9}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T15:16:05.581768231+02:00","mutation":{"Id":10}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T15:16:09.40673458+02:00","mutation":{"Id":11}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T15:16:11.41069986+02:00","mutation":{"Id":12}}
{"type":"AddItem","timestamp":"2026-06-14T15:24:17.09761136+02:00","mutation":{"item_id":148722,"quantity":1,"price":2531850,"orgPrice":3164813,"sku":"A0166079","name":"SF vridfönster 1980x1580mm 1-luft, insida trä utsida aluminium, 2-glas pga storleken","image":"/A0166079.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJhY2Nlc3NvcnlNYXRjaCI6IiIsImJvcmRlckhlaWdodCI6MTk0LCJib3JkZXJXaWR0aCI6MTk0LCJidWxreSI6MSwiY2F0ZWdvcnkiOjAsImRlbGV0ZSI6ZmFsc2UsImRlbGl2ZXJ5U3RyaW5nIjoiTm9ybWFsdCA0LTYgYXJiZXRzdmVja29yIiwiZGVsaXZlcnlXZWVrIjoxMCwiZGVzY3JpcHRpb24iOiJLYXJteXR0ZXJtw6V0dCBicmVkZCB4IGjDtmpkLiIsImRpdmlkZXJTaXplIjoxNjQsImdsYXNzTWFyZ2luUG9zdCI6NzEsImdyb3VwIjoiMTM3IiwiaGVpZ2h0IjoxNiwiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIxIiwibHVmdF90aXRsZSI6IjEtTHVmdCAoZW4gw7ZwcG5pbmdzYmFyIGLDpWdlIGkgc2FtbWEga2FybSkiLCJtb2RlbCI6IjEzNyIsIm1vZGVsRGVzY3JpcHRpb24iOiJTUCBCYWxhbnMgZsO2bnN0ZXIgdnJpZCIsInBhZ2VJZCI6ODg0MzYsInBvc3RTaXplIjo3MSwicHJldmlvdXNEaXNjb3VudCI6MzAsInNwcm9qc1NpemUiOjMxLCJzdG9ybGVrIjoiMjAxNiIsInRheG9ub215IjpbXSwidW5pdCI6InN0Iiwid2lkdGgiOjIwLCJ3aWR0aE1hcmdpbiI6MjB9"}}
{"type":"AddItem","timestamp":"2026-06-14T15:24:17.097670772+02:00","mutation":{"item_id":170852,"quantity":1,"sku":"A0190103","name":"Rundad profil på karm \u0026 båge, SP utseende","image":"/UserFiles/Svenska_Fonster/Diverse/Genomskarning_rund_profil.jpg","tax":2500,"sellerId":"7","sellerName":"Kaski","parentId":13,"extra_json":"eyJidWxreSI6MCwiY2F0ZWdvcnkiOjE3NCwiZGVsZXRlIjpmYWxzZSwiZGVsaXZlcnlTdHJpbmciOiJOb3JtYWx0IDQtNiBhcmJldHN2ZWNrb3IiLCJkZWxpdmVyeVdlZWsiOjEwLCJkZXNjcmlwdGlvbiI6IiIsImhlaWdodE1hcmdpbiI6MTAsImlzQWNjZXNzb3J5Ijp0cnVlLCJtb2RlbCI6IllEIiwibW9kZWxEZXNjcmlwdGlvbiI6Ikthc2tpIFl0dGVyZMO2cnJhciB0aGVybW8iLCJwYWdlSWQiOjAsInByZXZpb3VzRGlzY291bnQiOjIwLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoTWFyZ2luIjoxMH0="}}
{"type":"AddItem","timestamp":"2026-06-14T15:24:17.097710397+02:00","mutation":{"item_id":97103,"quantity":1,"price":4139744,"sku":"A0086072","name":"Brand klassat glas EI30 (välj även förankring)","image":"/UserFiles/Svenska_Fonster/Glas/Brand_Ei30.jpg","tax":2500,"sellerId":"0","parentId":13,"extra_json":"eyJhY2Nlc3NvcnlNYXRjaCI6IiIsImJ1bGt5IjowLCJjYXRlZ29yeSI6MjAsImRlbGV0ZSI6ZmFsc2UsImRlc2NyaXB0aW9uIjoiRXR0IGdsYXMgc29tIGtsYXJhciBrcmF2ZW4gRUkzMCIsImdyb3VwIjoiIiwiaXNBY2Nlc3NvcnkiOnRydWUsIm1vZGVsIjoiIiwicGFnZUlkIjowLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCJ9"}}
-45
View File
@@ -1,45 +0,0 @@
{
"version": 1,
"state": {
"promotions": [
{
"id": "volymrabatt",
"name": "Volymrabatt",
"description": "Automatisk volymrabatt baserad på varukorgens totalsumma (inkl. moms). Belopp i öre: 20 00040 000 kr → 5%, 40 00060 000 kr → 9%, 60 00090 000 kr → 14%.",
"status": "active",
"priority": 100,
"startDate": "2024-01-01",
"endDate": null,
"conditions": [
{
"id": "min-cart-total",
"type": "cart_total",
"operator": ">=",
"value": 2000000,
"label": "Minst 20 000 kr i varukorgen"
}
],
"actions": [
{
"id": "volymrabatt-tiers",
"type": "tiered_discount",
"value": 0,
"config": {
"tiers": [
{ "minTotal": 2000000, "maxTotal": 4000000, "percent": 5 },
{ "minTotal": 4000000, "maxTotal": 6000000, "percent": 9 },
{ "minTotal": 6000000, "maxTotal": 9000000, "percent": 14 }
]
},
"label": "Volymrabatt 514%"
}
],
"usageCount": 0,
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"createdBy": "mats.tornberg@gmail.com",
"tags": ["volume", "volymrabatt", "test"]
}
]
}
}
+4 -4
View File
@@ -76,9 +76,9 @@ spec:
memory: "70Mi" memory: "70Mi"
cpu: "1200m" cpu: "1200m"
env: env:
- name: CART_DIR - name: DATA_DIR
value: "/data/cart-actor" value: "/data/cart-actor"
- name: CHECKOUT_DIR - name: CHECKOUT_DATA_DIR
value: "/data/checkout-actor" value: "/data/checkout-actor"
- name: TZ - name: TZ
value: "Europe/Stockholm" value: "Europe/Stockholm"
@@ -184,9 +184,9 @@ spec:
memory: "70Mi" memory: "70Mi"
cpu: "1200m" cpu: "1200m"
env: env:
- name: CART_DIR - name: DATA_DIR
value: "/data/cart-actor" value: "/data/cart-actor"
- name: CHECKOUT_DIR - name: CHECKOUT_DATA_DIR
value: "/data/checkout-actor" value: "/data/checkout-actor"
- name: TZ - name: TZ
value: "Europe/Stockholm" value: "Europe/Stockholm"
+39 -37
View File
@@ -1,39 +1,42 @@
module git.k6n.net/mats/go-cart-actor module git.k6n.net/go-cart-actor
go 1.26.2 go 1.25.4
require ( require (
github.com/adyen/adyen-go-api-library/v21 v21.1.0 github.com/adyen/adyen-go-api-library/v21 v21.1.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/matst80/go-redis-inventory v0.0.0-20251126173508-51b30de2d86e
github.com/matst80/slask-finder v0.0.0-20251125182907-9e57f193127a
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/rabbitmq/amqp091-go v1.11.0 github.com/rabbitmq/amqp091-go v1.10.0
github.com/redis/go-redis/v9 v9.20.0 github.com/redis/go-redis/v9 v9.17.0
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 go.opentelemetry.io/contrib/bridges/otelslog v0.13.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0
go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel v1.38.0
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0
go.opentelemetry.io/otel/log v0.20.0 go.opentelemetry.io/otel/log v0.14.0
go.opentelemetry.io/otel/metric v1.44.0 go.opentelemetry.io/otel/metric v1.38.0
go.opentelemetry.io/otel/sdk v1.44.0 go.opentelemetry.io/otel/sdk v1.38.0
go.opentelemetry.io/otel/sdk/log v0.20.0 go.opentelemetry.io/otel/sdk/log v0.14.0
go.opentelemetry.io/otel/sdk/metric v1.44.0 go.opentelemetry.io/otel/sdk/metric v1.38.0
go.opentelemetry.io/otel/trace v1.44.0 go.opentelemetry.io/otel/trace v1.38.0
google.golang.org/grpc v1.81.1 google.golang.org/grpc v1.77.0
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.10
k8s.io/api v0.34.2 k8s.io/api v0.34.2
k8s.io/apimachinery v0.34.2 k8s.io/apimachinery v0.34.2
k8s.io/client-go v0.34.2 k8s.io/client-go v0.34.2
) )
require ( require (
github.com/RoaringBitmap/roaring/v2 v2.18.2 // indirect github.com/RoaringBitmap/roaring/v2 v2.14.4 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // 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/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589 // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
@@ -59,7 +62,7 @@ require (
github.com/google/gnostic-models v0.7.1 // indirect github.com/google/gnostic-models v0.7.1 // indirect
github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect
github.com/gorilla/schema v1.4.1 // indirect github.com/gorilla/schema v1.4.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.9.1 // indirect github.com/mailru/easyjson v0.9.1 // indirect
@@ -74,8 +77,8 @@ require (
github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // 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/client_model v0.6.2 // indirect
github.com/prometheus/common v0.68.0 // indirect github.com/prometheus/common v0.67.4 // indirect
github.com/prometheus/procfs v0.20.1 // indirect github.com/prometheus/procfs v0.19.2 // indirect
github.com/speakeasy-api/jsonpath v0.6.2 // indirect github.com/speakeasy-api/jsonpath v0.6.2 // indirect
github.com/speakeasy-api/openapi-overlay v0.10.3 // indirect github.com/speakeasy-api/openapi-overlay v0.10.3 // indirect
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
@@ -83,23 +86,22 @@ require (
github.com/woodsbury/decimal128 v1.4.0 // indirect github.com/woodsbury/decimal128 v1.4.0 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
golang.org/x/mod v0.35.0 // indirect golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.55.0 // indirect golang.org/x/net v0.47.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/oauth2 v0.33.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.45.0 // indirect golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.43.0 // indirect golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect
golang.org/x/time v0.15.0 // indirect golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.44.0 // indirect golang.org/x/tools v0.39.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
+78 -84
View File
@@ -1,5 +1,5 @@
github.com/RoaringBitmap/roaring/v2 v2.18.2 h1:oPq3Cgx//iDuJQVp6xSInAKW34J9CEwE5GmLI2z+Eic= github.com/RoaringBitmap/roaring/v2 v2.14.4 h1:4aKySrrg9G/5oRtJ3TrZLObVqxgQ9f1znCRBwEwjuVw=
github.com/RoaringBitmap/roaring/v2 v2.18.2/go.mod h1:eq4wdNXxtJIS/oikeCzdX1rBzek7ANzbth041hrU8Q4= github.com/RoaringBitmap/roaring/v2 v2.14.4/go.mod h1:oMvV6omPWr+2ifRdeZvVJyaz+aoEUopyv5iH0u/+wbY=
github.com/adyen/adyen-go-api-library/v21 v21.1.0 h1:QIKtn99yoBdt2R4PhuMdmY/DTm6Ex5HYd0cB7Sh3y6Y= github.com/adyen/adyen-go-api-library/v21 v21.1.0 h1:QIKtn99yoBdt2R4PhuMdmY/DTm6Ex5HYd0cB7Sh3y6Y=
github.com/adyen/adyen-go-api-library/v21 v21.1.0/go.mod h1:qsAGYetm761eDAz+f2OQoY4qC+tKNhZOHil1b4FO5zE= github.com/adyen/adyen-go-api-library/v21 v21.1.0/go.mod h1:qsAGYetm761eDAz+f2OQoY4qC+tKNhZOHil1b4FO5zE=
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
@@ -20,6 +20,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/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 h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
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 h1:VJ/jVUWr+r4MQA7U/cscbbXRuwh1PfPCUUItYAjlKN4=
github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589/go.mod h1:IeI20psFPeg2n1jxwbkYCmkpYsXsJqB7qmoqCIlX80s= github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589/go.mod h1:IeI20psFPeg2n1jxwbkYCmkpYsXsJqB7qmoqCIlX80s=
@@ -93,8 +95,8 @@ 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/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 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= 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/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
@@ -106,8 +108,6 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -119,10 +119,10 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= 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/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
git.k6n.net/mats/go-redis-inventory v0.0.0-20251126173508-51b30de2d86e h1:Z7A73W6jsxFuFKWvB1efQmTjs0s7+x2B7IBM2ukkI6Y= github.com/matst80/go-redis-inventory v0.0.0-20251126173508-51b30de2d86e h1:Z7A73W6jsxFuFKWvB1efQmTjs0s7+x2B7IBM2ukkI6Y=
git.k6n.net/mats/go-redis-inventory v0.0.0-20251126173508-51b30de2d86e/go.mod h1:9P52UwIlLWLZvObfO29aKTWUCA9Gm62IuPJ/qv4Xvs0= github.com/matst80/go-redis-inventory v0.0.0-20251126173508-51b30de2d86e/go.mod h1:9P52UwIlLWLZvObfO29aKTWUCA9Gm62IuPJ/qv4Xvs0=
git.k6n.net/mats/slask-finder v0.0.0-20251125182907-9e57f193127a h1:EfUO5BNDK3a563zQlwJYTNNv46aJFT9gbSItAwZOZ/Y= github.com/matst80/slask-finder v0.0.0-20251125182907-9e57f193127a h1:EfUO5BNDK3a563zQlwJYTNNv46aJFT9gbSItAwZOZ/Y=
git.k6n.net/mats/slask-finder v0.0.0-20251125182907-9e57f193127a/go.mod h1:VIPNkIvU0dZKwbSuv75zZcB93MXISm2UyiIPly/ucXQ= github.com/matst80/slask-finder v0.0.0-20251125182907-9e57f193127a/go.mod h1:VIPNkIvU0dZKwbSuv75zZcB93MXISm2UyiIPly/ucXQ=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -161,14 +161,14 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= 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 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.68.0 h1:8rQJvQmYltsR2L7h8Zw0Iyj8WYNNmpwikoQTZXwfVeA= github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
github.com/prometheus/common v0.68.0/go.mod h1:4soH+U8yJSROk7OJ//hmTiWKsxapv6zRGgTt3keN8gQ= github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/rabbitmq/amqp091-go v1.11.0 h1:HxIctVm9Gid/Vtn706necmZ7Wj6pgGI2eqplRbEY8O8= github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
github.com/rabbitmq/amqp091-go v1.11.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0= github.com/redis/go-redis/v9 v9.17.0 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM=
github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/redis/go-redis/v9 v9.17.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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
@@ -199,48 +199,42 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= 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/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 h1:5RgvxieNq9tS3ewrV1vnODvbHPfKUIJcYtF9Cvz+6aQ= go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 h1:bwnLpizECbPr1RrQ27waeY2SPIPeccCx/xLuoYADZ9s=
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0/go.mod h1:iTBIdNwx/xmUhfgJs6+84S4dIK059811cO1eUBjKcHY= go.opentelemetry.io/contrib/bridges/otelslog v0.13.0/go.mod h1:3nWlOiiqA9UtUnrcNk82mYasNxD8ehOspL0gOfEo6Y4=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 h1:OMqPldHt79PqWKOMYIAQs3CxAi7RLgPxwfFSwr4ZxtM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0/go.mod h1:1biG4qiqTxKiUCtoWDPpL3fB3KxVwCiGw81j3nKMuHE=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 h1:vl9obrcoWVKp/lwl8tRE33853I8Xru9HFbw/skNeLs8=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0/go.mod h1:GAXRxmLJcVM3u22IjTg74zWBrRCKq8BnOqUVLodpcpw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk=
go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs= go.opentelemetry.io/otel/log v0.14.0 h1:2rzJ+pOAZ8qmZ3DDHg73NEKzSZkhkGIua9gXtxNGgrM=
go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E= go.opentelemetry.io/otel/log v0.14.0/go.mod h1:5jRG92fEAgx0SU/vFPxmJvhIuDU9E1SUnEQrMlJpOno=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= go.opentelemetry.io/otel/sdk/log v0.14.0 h1:JU/U3O7N6fsAXj0+CXz21Czg532dW2V4gG1HE/e8Zrg=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= go.opentelemetry.io/otel/sdk/log v0.14.0/go.mod h1:imQvII+0ZylXfKU7/wtOND8Hn4OpT3YUoIgqJVksUkM=
go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY= go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 h1:Ijbtz+JKXl8T2MngiwqBlPaHqc4YCaP/i13Qrow6gAM=
go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU= go.opentelemetry.io/otel/sdk/log/logtest v0.14.0/go.mod h1:dCU8aEL6q+L9cYTqcVOk8rM9Tp8WdnHOPLiBgp0SGOA=
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0 h1:OqdRZ1guyzamK3M6LlRsmGqRrjkHWw6WZOKKli5ELpg= go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0/go.mod h1:PuMIlm7zAt7c3z8zfOI5ox4iT1Z87We+PF6YoINux/M= go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= 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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
@@ -250,57 +244,57 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/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.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= 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-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-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-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-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= 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-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-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-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.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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-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-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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= 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.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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= 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-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-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-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= 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-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-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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846 h1:ZdyUkS9po3H7G0tuh955QVyyotWvOD4W0aEapeGeUYk=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846/go.mod h1:Fk4kyraUvqD7i5H6S43sj2W98fbZa75lpZz/eUyhfO0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 h1:Wgl1rcDNThT+Zn47YyCXOXyX/COgMTIdhJ717F0l4xk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
-7
View File
@@ -1,7 +0,0 @@
go 1.26.2
use (
.
../slask-finder
../go-redis-inventory
)
-163
View File
@@ -1,163 +0,0 @@
cloud.google.com/go/accessapproval v1.13.0/go.mod h1:7bmInw17bQX+ZPi7YmReC3xKymDrMmxXaUnaI6zQOqI=
cloud.google.com/go/accesscontextmanager v1.14.0/go.mod h1:VO15iVnsM0FO9Dt8hSFPgkuHRZjq6LEYZq1szJ27U2k=
cloud.google.com/go/aiplatform v1.125.0/go.mod h1:yWTZiCunYDnyxeWWD14tDo6+BMlvAUCC5VxuxhvbrVI=
cloud.google.com/go/analytics v0.35.0/go.mod h1:V9Qef2N0y8GDqQ9FTlmM2XpDEMYonZJRPSUNGZlPCcc=
cloud.google.com/go/apigateway v1.12.0/go.mod h1:f3Sk8Tdh1Ty5HR7kgbWB6Yu1M82LM+nIr5DTMZnLZWk=
cloud.google.com/go/apigeeconnect v1.12.0/go.mod h1:mYJekCKZHc2ia5yZX5lwtexTn9CzsOfb6+sh/2hi42Q=
cloud.google.com/go/apigeeregistry v1.0.0/go.mod h1:o+j6eA8hYhTWX5gEqMMBVDWY+/QQFrYe/YJBsO19pn0=
cloud.google.com/go/appengine v1.14.0/go.mod h1:JMjrVFg+YgfksZCWbtA3TgbKbPfZZtapB9cGL/5WVnM=
cloud.google.com/go/area120 v0.15.0/go.mod h1:jD1fw9W4xxIZMY68g7PpbCPleoeGddFs5jPcdhfg3+Y=
cloud.google.com/go/artifactregistry v1.25.0/go.mod h1:aMmdtqKVmbuxCCb/NGDJYZHsK6AtqlcyvD05ACzs1n8=
cloud.google.com/go/asset v1.27.0/go.mod h1:+HaDReZQAh/0syAf0uTMeUrMfXikr+KKyDtCdvf7j4M=
cloud.google.com/go/assuredworkloads v1.18.0/go.mod h1:zBnVYn0E+sDW/mhEmcg1R8+8tguXrtBgmfGY0q34kss=
cloud.google.com/go/automl v1.20.0/go.mod h1:OkHxjbVDblDafhwuP8yEkz1xcUJhgcbhbsieCW7GaiI=
cloud.google.com/go/baremetalsolution v1.9.0/go.mod h1:o+stutiS8t+HmjNIG92Gkn8H9+5/q27d6lQp7e9GWdg=
cloud.google.com/go/batch v1.19.0/go.mod h1:dpWfhLmLQZqsTBAFYjZA3pS04fCY5ttTenZcWmSeILw=
cloud.google.com/go/beyondcorp v1.7.0/go.mod h1:vujdO0wfsBV2y1egrJxGtwKZr5P5V6bIHKWp1phWHBY=
cloud.google.com/go/bigquery v1.77.0/go.mod h1:J4wuqka/1hEpdJxH2oBrUR0vjTD+r7drGkpcA3yqERM=
cloud.google.com/go/bigtable v1.47.0/go.mod h1:GUM6PdkG3rrDse9kugqvX5+ktwo3ldfLtLi1VFn5Wj4=
cloud.google.com/go/billing v1.26.0/go.mod h1:axqDO1uHegh7u5qngkTfqN1djAeLGsWAFAblERgmgEk=
cloud.google.com/go/binaryauthorization v1.15.0/go.mod h1:+0CndCJPtcHuVCNok+qQskWvbP5Sp5m6eGL8Vpu5mss=
cloud.google.com/go/certificatemanager v1.14.0/go.mod h1:QOA8qRoM6/Ik03+srLnBykenGTy0fk78dnPcx5ZWOW8=
cloud.google.com/go/channel v1.26.0/go.mod h1:04T5Wjq+mHlvEUNzExydnBW1vO64q3Q2Wsblp/dpBxY=
cloud.google.com/go/cloudbuild v1.30.0/go.mod h1:rg52xEmndQQPiC9NV/8sCaVtKxHMU9D9MeU+oE9VGKA=
cloud.google.com/go/clouddms v1.13.0/go.mod h1:aMgrOZ+/EKF/PL+h1sDbS+7fAIYV5rTwD+G/apCeHQk=
cloud.google.com/go/cloudtasks v1.18.0/go.mod h1:3KeCxwtGEyaySL7CR3lMmEa2I4mq1ynXdgmfNiO4RYE=
cloud.google.com/go/compute v1.64.0 h1:7MmuzeAxlG5MOG5PQD2NLtyYR6bWjkvGljRu7pByoRU=
cloud.google.com/go/compute v1.64.0/go.mod h1:eHhcRZ6vf70fQCS3VEsiWSh+nQ+tLvSMb7mwLQskgN0=
cloud.google.com/go/contactcenterinsights v1.22.0/go.mod h1:2Crd36H59Lwkt4gWrLgmnbnF59IIZIa3XYt1gtNqJkQ=
cloud.google.com/go/container v1.52.0/go.mod h1:EvqoT2eXfxLweXXUlhAMGR0sOAB00XPzEjoL01esSDs=
cloud.google.com/go/containeranalysis v0.19.0/go.mod h1:Zq0XHzUIa0oTa7H6aSR8HWqeJnoRI9syUcYJzfozjZQ=
cloud.google.com/go/datacatalog v1.32.0/go.mod h1:DE272tynQUwheJeQAyVfV+nO8yrdkuDyOgH2LtOrkWM=
cloud.google.com/go/dataflow v0.16.0/go.mod h1:BWhSrIGmsMfuYj3J+nJ2Tw7tplRR6r28kvRiqCD3WlQ=
cloud.google.com/go/dataform v1.0.0/go.mod h1:i1a0zkS751kvrY1IIPpUQZ77H5doxx7cs0AP3hnXTMk=
cloud.google.com/go/datafusion v1.13.0/go.mod h1:MQdANs3I/4gitzY+mTBx27rrQyMiUg8uc2Z4TPLWWfc=
cloud.google.com/go/datalabeling v0.14.0/go.mod h1:DYjvP4RhQ0332YgO22APYlBjCebb+SCaS0e2KApDq/Q=
cloud.google.com/go/dataplex v1.34.0/go.mod h1:sOazL+Bs/PTxiMHQ5yBboBvEW9qPrpGogx3+RAgfIt8=
cloud.google.com/go/dataproc/v2 v2.22.0/go.mod h1:oARVSa38kAHvSuG+cozsrY2sE6UajGuvOOf9vS+ADHI=
cloud.google.com/go/dataqna v0.13.0/go.mod h1:XiVVFTOEJLBSvm3ILbyjXngGQYpjb/66MSksqz/56fs=
cloud.google.com/go/datastore v1.24.0/go.mod h1:cEkLhU6Ti/gauQ7DFrUrG8bQjiMIxi++b5ePiThi5So=
cloud.google.com/go/datastream v1.20.0/go.mod h1:uoWTtfP20W8MXuV2DPcl5zqnVsxQ9QEmmBHX858oYTQ=
cloud.google.com/go/deploy v1.32.0/go.mod h1:lUG7maG/NkoTXmQ8G1mtcVymnbizfDJh6ER7vljVa/U=
cloud.google.com/go/dialogflow v1.82.0/go.mod h1:UtuiGOq9gAlTz9u4Vt+q1syMrx9ANQzTk+lC3WDdSOw=
cloud.google.com/go/dlp v1.34.0/go.mod h1:+haQd/n0QTv5BK7wZnCk2qctd5sfKL50jjh9E6N0d/Q=
cloud.google.com/go/documentai v1.48.0/go.mod h1:mGjfbNf0cqCHKgxMZZV7frbfoF9T2hKkU1h88QyOy3c=
cloud.google.com/go/domains v0.15.0/go.mod h1:BjoSVNc+LVwoHMnE2fxTQNzGLSWWb6f3a8VAN6+VjVk=
cloud.google.com/go/edgecontainer v1.9.0/go.mod h1:mZmgXuMGTGI6RUUTXsOZa+F2rFF21v0JPnuX7LQEqBE=
cloud.google.com/go/errorreporting v0.9.0/go.mod h1:V7ojx7z76JITDZNGyDNkIIa9nNEkQzF6Yj+VHl2YF84=
cloud.google.com/go/essentialcontacts v1.12.0/go.mod h1:W8fTL17jP6vmsPHQaCT5rOjWGohEssuqDUroxnjST0A=
cloud.google.com/go/eventarc v1.23.0/go.mod h1:tIJL0hoWtZXVa5MjcAep/4xB+AXz4AbqQV14ogX5VwU=
cloud.google.com/go/filestore v1.15.0/go.mod h1:oD+PvCWu4HqfEdNv65yk2XaLIiP7h4AuAH9Ua5YBRTM=
cloud.google.com/go/functions v1.24.0/go.mod h1:t40GeqBAQNuqKlHCxmV/pxhyYJnImLcvRa3GBv4tAy0=
cloud.google.com/go/gkebackup v1.13.0/go.mod h1:D2MDbHW4V/uKCmS9TnT8hNKX2tPkE/pWp9nSm0TQ9hY=
cloud.google.com/go/gkeconnect v1.0.0/go.mod h1:5iWSBQzMIRLwUHUWVhxxcNK45ZPE8ntyBgE0MkavlqQ=
cloud.google.com/go/gkehub v0.21.0/go.mod h1:xKePlMrI8LpKErzKMWdH/yQv+GDV60ypCNfTTdT+BN0=
cloud.google.com/go/gkemulticloud v1.11.0/go.mod h1:OtfHtgqOgDrXfcdFw8eUkCUI154Q51vvdqZYZV4c4qM=
cloud.google.com/go/gsuiteaddons v1.12.0/go.mod h1:rm/XT7wmwOFGn7jmWtVV65QmZCakzTbHLSojIC4Hskg=
cloud.google.com/go/iap v1.17.0/go.mod h1:b+r+yjrss2WmAEzNrQQjlEdD5E9B8c47mOF7XnqT+z0=
cloud.google.com/go/ids v1.10.0/go.mod h1:uCSFrXfCnRUKBl5PdE/ZqBNp1+vKSKPWpdYGa61WjpQ=
cloud.google.com/go/iot v1.13.0/go.mod h1:62W4n2fe/Ct66NWJEfCB5suZ3XsL5Atx+MxFjScr+9s=
cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U=
cloud.google.com/go/language v1.18.0/go.mod h1:xSeiVB4UiA9wYmFy2GWjf1Mb1K3uR1Yi/80qoqTxH04=
cloud.google.com/go/lifesciences v0.15.0/go.mod h1:FwS+QkqPdVWl4SmKUCFozFvsTVWTLH13HCKcwR/MR9U=
cloud.google.com/go/managedidentities v1.12.0/go.mod h1:rm72jf/v//0NG73VQNZM1JlV2E95uhJymmSXlgi6hMA=
cloud.google.com/go/maps v1.35.0/go.mod h1:HH1V8tduMn+b9oRMCdl3vok98uvHco/wElZXyJQ/9kU=
cloud.google.com/go/mediatranslation v0.13.0/go.mod h1:kjZrowuigFr+Bf1HM1TCtp1a3E3kfG1ovPK5VEuaNAQ=
cloud.google.com/go/memcache v1.16.0/go.mod h1:y/rXhJiieCF742K958dY29fSfM+Y3wh2thRmWspU2Dg=
cloud.google.com/go/metastore v1.19.0/go.mod h1:JGTjGdQ627m2ptDo86XsIKqzzZCk+GG41VEFD7ENsqs=
cloud.google.com/go/networkconnectivity v1.26.0/go.mod h1:Uhzfk7NbiY6RNqV9XFvPWRji58+MkTYsTRfQ3EPtrGg=
cloud.google.com/go/networkmanagement v1.28.0/go.mod h1:2YogSU3sD7LvtmWntUAuGARbFQmy3A0En3LrJr69jkU=
cloud.google.com/go/networksecurity v0.16.0/go.mod h1:LMn10eRVf4K85PMF33yRoKAra7VhCOetxFcLDMh9A74=
cloud.google.com/go/notebooks v1.17.0/go.mod h1:NScGIhfQCqLRIlVaUVbm595F6dhqiTl5XS1KaKgitKM=
cloud.google.com/go/optimization v1.11.0/go.mod h1:qCWskZMcynh0GBsUrCP6oPwwnUhbwg5UcXvVM9hzOD8=
cloud.google.com/go/orchestration v1.16.0/go.mod h1:H7MFVP8Z/dtml39nf43sWYPL/2o7J4tdSZAlJrBuqnQ=
cloud.google.com/go/orgpolicy v1.20.0/go.mod h1:9LHqEGx5P5dhansdKTNIEXpM+QbebAIOs66+HUID4aQ=
cloud.google.com/go/osconfig v1.21.0/go.mod h1:BofnHqjjvu6lZQv/hqo2+rLCUiY4O6A9UYwwvVrSBjk=
cloud.google.com/go/oslogin v1.18.0/go.mod h1:3Oa36T3781Mv+yCSVYlfasi7auHjfPFqvNOd1q92umc=
cloud.google.com/go/phishingprotection v0.13.0/go.mod h1:2gyYqwNjePPEocXDkDve3EuJPaRqN/E7fp28K3arR0k=
cloud.google.com/go/policytroubleshooter v1.15.0/go.mod h1:yNuROjN6h+2/TE2JOvBBJMjYIjC6j0UYHq8f2kVHlA4=
cloud.google.com/go/privatecatalog v0.15.0/go.mod h1:av2b5Rv+oG5ORxUqGlCAYO9s4pXjgc6q2qO9nkTcqT8=
cloud.google.com/go/pubsub v1.50.2/go.mod h1:jyCWeZdGFqd4mitSsBERnJcpqaHBsxQoPkNvjj4sp0w=
cloud.google.com/go/pubsub/v2 v2.5.1/go.mod h1:Pd+qeabMX+576vQJhTN7TelE4k6kJh15dLU/ptOQ/UA=
cloud.google.com/go/pubsublite v1.8.2/go.mod h1:4r8GSa9NznExjuLPEJlF1VjOPOpgf3IT6k8x/YgaOPI=
cloud.google.com/go/recaptchaenterprise/v2 v2.26.0/go.mod h1:+ntF70/j7qBa6G/pwmYA0mkBcDeTCXV6WDqUL7GObfs=
cloud.google.com/go/recommendationengine v0.14.0/go.mod h1:UP9cN46tDpZ/N57eDYIWeIRHjMOchtiIyjWjV0Dvr3k=
cloud.google.com/go/recommender v1.18.0/go.mod h1:INRBLfBQJCrgPqjBVFht4OjaFq/WhB/c5V1sqBOdX8g=
cloud.google.com/go/redis v1.23.0/go.mod h1:EUlUT24BAL6LsE1f/N9Bg3LhRCfH+LzwLGbst3KuZRw=
cloud.google.com/go/resourcemanager v1.15.0/go.mod h1:ve0VNxPoDU6XxDuEMCjkineb0YzXQXx3mOWwnNckGDE=
cloud.google.com/go/resourcesettings v1.8.3/go.mod h1:BzgfXFHIWOOmHe6ZV9+r3OWfpHJgnqXy8jqwx4zTMLw=
cloud.google.com/go/retail v1.31.0/go.mod h1:sfq/cT+gfSLuURf/mdVAw5n0pav3hxSP1rT8RfL7Qxk=
cloud.google.com/go/run v1.21.0/go.mod h1:Z5wHbyFirI8XU48EPs5XJf/qmVm1SXZEhuS8EvZOuQU=
cloud.google.com/go/scheduler v1.16.0/go.mod h1:0hsZg0MZJADyke1lutI0FHAYJR8Dtm8oIivXkmpACkA=
cloud.google.com/go/secretmanager v1.20.0/go.mod h1:9OmSuOeiiUicANglrbdKWSnT3gYkRcXuUQDk7dDW0zU=
cloud.google.com/go/security v1.24.0/go.mod h1:XaB3p0SE7v2bBitsLBb1hM6R8/oI/k/IujpXFJalFK0=
cloud.google.com/go/securitycenter v1.44.0/go.mod h1:7BMMbSTAddVfiE+HrC8tKS6SuRkyK7FRPlkpAZBRV3U=
cloud.google.com/go/servicedirectory v1.17.0/go.mod h1:CtgjXS1idj3s9Q6tB68021Rzk8Q6decV6+ldXC1BoBk=
cloud.google.com/go/shell v1.12.0/go.mod h1:TivWrVriy6xQ0wBjNJJridJgODZz8zXUEW2u48kynzY=
cloud.google.com/go/spanner v1.91.0/go.mod h1:8NB5a7qgwIhGD19Ly+vkpKffPL78vIG9RcrgsuREha0=
cloud.google.com/go/speech v1.35.0/go.mod h1:shnf33sZbGnQQZyek1fdLOR5rRKV6D3jsNqpqyijvj8=
cloud.google.com/go/storagetransfer v1.18.0/go.mod h1:AbGutEym/KNasoiDpSj/CYbigp5yhgosSgwlhGvQNs4=
cloud.google.com/go/talent v1.13.0/go.mod h1:GSwli9V25WQdzeuJDJWH9TlQmA8lPFn7yKsxowdxW9Y=
cloud.google.com/go/texttospeech v1.21.0/go.mod h1:p/UVJILAo/S5vsJaWZVdDRzNzA7wXIA+hTACvpMeOBk=
cloud.google.com/go/tpu v1.13.0/go.mod h1:F5gT5BL22Dhsr05JLHdMjAjj+wcTn3Xtuu4jvq9yFug=
cloud.google.com/go/translate v1.17.0/go.mod h1:3mErnHTQBu9yeLiL35K0HBBuaM6Vk2fD/vyWFz790VU=
cloud.google.com/go/video v1.32.0/go.mod h1:KxDL728ZzH+FJwtEb9XkiLTETW5bI37hTWbJiRYeXkk=
cloud.google.com/go/videointelligence v1.16.0/go.mod h1:mmX1JpIWzwozaigrdRNjikZc3aFLNHFKh+OFwAdfiW4=
cloud.google.com/go/vision/v2 v2.14.0/go.mod h1:ODlLCajJOq4t8thoi1uVvbnfIfix73HsYWhZuIveagQ=
cloud.google.com/go/vmmigration v1.15.0/go.mod h1:MP6mQ21ru1usBeCbl805Ioz0Fy+yf3qK2kUkhZ69QQY=
cloud.google.com/go/vmwareengine v1.8.0/go.mod h1:e66l90IZhm1yQfYZv+YCWjSNSklQZCRmuEvKL8n3Ua0=
cloud.google.com/go/vpcaccess v1.13.0/go.mod h1:4Uus6E/9FYUtIrwBE1wJ1RosKwb02H6kEd9puJ02TL8=
cloud.google.com/go/webrisk v1.16.0/go.mod h1:VIQw8smiaMOlget/xOk6niTkNJTiQc5skEmCuAksxJc=
cloud.google.com/go/websecurityscanner v1.12.0/go.mod h1:cZSc9HqoFdccL1mqZtPIInOd4R8PBGwI20wdnrz6AO8=
cloud.google.com/go/workflows v1.19.0/go.mod h1:TWsrDGgsJy7xAJ07byzHhKKehEWItJG3BivEHVhGH5g=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY=
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/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/lyft/protoc-gen-star/v2 v2.0.4/go.mod h1:amey7yeodaJhXSbf/TlLvWiqQfLOSpEk//mLlc+axEk=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mdempsky/unconvert v0.0.0-20250216222326-4a038b3d31f5/go.mod h1:mVCHGHs8r8jnrZ2ammcv8ySbhG2+rEPXegFmdNA51GI=
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE=
golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genproto/googleapis/bytestream v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:6TABGosqSqU2l1+fJ3jdvOYPPVryeKybxYF0cCZkTBE=
google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6/go.mod h1:6ytKWczdvnpnO+m+JiG9NjEDzR1FJfsnmJdG7B8QVZ8=
gotest.tools/gotestsum v1.12.3/go.mod h1:Y1+e0Iig4xIRtdmYbEV7K7H6spnjc1fX4BOuUhWw2Wk=
k8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU=
@@ -1,95 +0,0 @@
package customerauth
import (
"path/filepath"
"testing"
"time"
)
func TestPasswordRoundTrip(t *testing.T) {
h, err := HashPassword("correct horse battery staple")
if err != nil {
t.Fatalf("hash: %v", err)
}
if !VerifyPassword("correct horse battery staple", h) {
t.Fatal("correct password did not verify")
}
if VerifyPassword("wrong", h) {
t.Fatal("wrong password verified")
}
}
func TestPasswordHashesAreSalted(t *testing.T) {
a, _ := HashPassword("hunter2hunter2")
b, _ := HashPassword("hunter2hunter2")
if a == b {
t.Fatal("two hashes of the same password are identical — not salted")
}
}
func TestVerifyRejectsMalformed(t *testing.T) {
for _, bad := range []string{"", "x", "pbkdf2-sha256$abc$salt$hash", "md5$1$a$b"} {
if VerifyPassword("whatever", bad) {
t.Fatalf("malformed hash %q verified", bad)
}
}
}
func TestSessionRoundTrip(t *testing.T) {
s := NewSigner([]byte("test-secret"))
tok := s.Issue(12345, time.Hour)
id, err := s.Parse(tok)
if err != nil {
t.Fatalf("parse: %v", err)
}
if id != 12345 {
t.Fatalf("got id %d, want 12345", id)
}
}
func TestSessionExpired(t *testing.T) {
s := NewSigner([]byte("test-secret"))
tok := s.Issue(1, -time.Second)
if _, err := s.Parse(tok); err != ErrExpiredToken {
t.Fatalf("got %v, want ErrExpiredToken", err)
}
}
func TestSessionTamperAndWrongKey(t *testing.T) {
s := NewSigner([]byte("test-secret"))
tok := s.Issue(7, time.Hour)
if _, err := s.Parse(tok + "x"); err != ErrInvalidToken {
t.Fatalf("tampered token: got %v, want ErrInvalidToken", err)
}
other := NewSigner([]byte("different-secret"))
if _, err := other.Parse(tok); err != ErrInvalidToken {
t.Fatalf("wrong key: got %v, want ErrInvalidToken", err)
}
}
func TestStoreRegisterDuplicateAndReload(t *testing.T) {
path := filepath.Join(t.TempDir(), "credentials.json")
store, err := LoadCredentialStore(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if err := store.Register("User@Example.com", 42, "hash1", "ts"); err != nil {
t.Fatalf("register: %v", err)
}
// Duplicate (case-insensitive) is rejected.
if err := store.Register("user@example.com", 99, "hash2", "ts"); err != ErrEmailExists {
t.Fatalf("duplicate: got %v, want ErrEmailExists", err)
}
// Reload from disk and confirm the record persisted.
reloaded, err := LoadCredentialStore(path)
if err != nil {
t.Fatalf("reload: %v", err)
}
rec, ok := reloaded.Get("USER@EXAMPLE.COM")
if !ok {
t.Fatal("record not found after reload")
}
if rec.ProfileID != 42 || rec.Hash != "hash1" {
t.Fatalf("unexpected record: %+v", rec)
}
}
-76
View File
@@ -1,76 +0,0 @@
// Package customerauth adds password-based signup/login to the profile service.
//
// It deliberately uses only the standard library: PBKDF2-HMAC-SHA256 for
// password hashing (crypto/pbkdf2, Go 1.24+) and HMAC-SHA256-signed cookies for
// sessions (crypto/hmac). No password ever leaves the server in plaintext and
// the session token is opaque + tamper-evident.
package customerauth
import (
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"fmt"
"strconv"
"strings"
)
// pbkdf2Iterations is the work factor for PBKDF2-HMAC-SHA256. 210_000 matches
// the current OWASP recommendation for PBKDF2-SHA256.
const pbkdf2Iterations = 210_000
const (
saltLen = 16
keyLen = 32
)
// HashPassword returns an encoded PBKDF2 hash of the form
//
// pbkdf2-sha256$<iterations>$<saltB64>$<hashB64>
//
// with a fresh random salt. The encoded string is self-describing so Verify can
// read the parameters back without external configuration.
func HashPassword(password string) (string, error) {
salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("customerauth: read salt: %w", err)
}
dk, err := pbkdf2.Key(sha256.New, password, salt, pbkdf2Iterations, keyLen)
if err != nil {
return "", fmt.Errorf("customerauth: pbkdf2: %w", err)
}
return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s",
pbkdf2Iterations,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(dk),
), nil
}
// VerifyPassword reports whether password matches the encoded hash. It is
// constant-time in the hash comparison and returns false for any malformed
// encoding rather than erroring, so callers can treat it as a simple predicate.
func VerifyPassword(password, encoded string) bool {
parts := strings.Split(encoded, "$")
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
return false
}
iter, err := strconv.Atoi(parts[1])
if err != nil || iter <= 0 {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[2])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[3])
if err != nil {
return false
}
got, err := pbkdf2.Key(sha256.New, password, salt, iter, len(want))
if err != nil {
return false
}
return subtle.ConstantTimeCompare(got, want) == 1
}
-378
View File
@@ -1,378 +0,0 @@
package customerauth
import (
"context"
"encoding/json"
"net/http"
"net/mail"
"strings"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/profile"
messages "git.k6n.net/mats/go-cart-actor/proto/profile"
"google.golang.org/protobuf/proto"
)
// minPasswordLen is the minimum accepted password length at registration.
const minPasswordLen = 8
// ProfileApplier is the subset of the profile grain pool the auth server needs.
// The pool (and the UCP adapter's ProfileApplier) already satisfies it.
type ProfileApplier interface {
Get(ctx context.Context, id uint64) (*profile.ProfileGrain, error)
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error)
}
// Server exposes password signup/login and identity-linking over HTTP, backed
// by the credential store (email→id+hash) and the profile grain pool.
type Server struct {
store *CredentialStore
applier ProfileApplier
signer *Signer
ttl time.Duration
}
// NewServer builds an auth server. ttl<=0 falls back to DefaultSessionTTL.
func NewServer(store *CredentialStore, applier ProfileApplier, signer *Signer, ttl time.Duration) *Server {
if ttl <= 0 {
ttl = DefaultSessionTTL
}
return &Server{store: store, applier: applier, signer: signer, ttl: ttl}
}
// Handler returns the router for the auth endpoints. Mount it under /ucp/v1/auth
// (the prefix is stripped by the caller).
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /register", s.handleRegister)
mux.HandleFunc("POST /login", s.handleLogin)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /me", s.handleMe)
mux.HandleFunc("POST /link-cart", s.handleLinkCart)
mux.HandleFunc("POST /link-checkout", s.handleLinkCheckout)
mux.HandleFunc("POST /link-order", s.handleLinkOrder)
return mux
}
// ---------------------------------------------------------------------------
// Request/response payloads
// ---------------------------------------------------------------------------
type registerRequest struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name,omitempty"`
Phone string `json:"phone,omitempty"`
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type linkCartRequest struct {
CartID string `json:"cartId"`
}
type linkCheckoutRequest struct {
CheckoutID string `json:"checkoutId"`
CartID string `json:"cartId"`
}
type linkOrderRequest struct {
OrderReference string `json:"orderReference"`
CartID string `json:"cartId"`
Status string `json:"status,omitempty"`
}
// CustomerResponse mirrors the UCP customer projection (kept local to avoid an
// import cycle with internal/ucp). The password hash is never included.
type CustomerResponse struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Phone string `json:"phone,omitempty"`
Language string `json:"language,omitempty"`
Currency string `json:"currency,omitempty"`
AvatarURL string `json:"avatarUrl,omitempty"`
Addresses []AddressResponse `json:"addresses"`
Orders []OrderRef `json:"orders"`
}
type AddressResponse struct {
ID uint32 `json:"id"`
Label string `json:"label,omitempty"`
FullName string `json:"fullName,omitempty"`
AddressLine1 string `json:"addressLine1"`
AddressLine2 string `json:"addressLine2,omitempty"`
City string `json:"city"`
Zip string `json:"zip"`
Country string `json:"country"`
IsDefaultShipping bool `json:"isDefaultShipping,omitempty"`
IsDefaultBilling bool `json:"isDefaultBilling,omitempty"`
}
type OrderRef struct {
OrderReference string `json:"orderReference"`
Status string `json:"status,omitempty"`
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
var req registerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
email := NormalizeEmail(req.Email)
if !validEmail(email) {
writeErr(w, http.StatusBadRequest, "a valid email is required")
return
}
if len(req.Password) < minPasswordLen {
writeErr(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
if _, exists := s.store.Get(email); exists {
writeErr(w, http.StatusConflict, "an account with this email already exists")
return
}
hash, err := HashPassword(req.Password)
if err != nil {
writeErr(w, http.StatusInternalServerError, "could not hash password")
return
}
rawID, err := profile.NewProfileId()
if err != nil {
writeErr(w, http.StatusInternalServerError, "could not generate id")
return
}
id := uint64(rawID)
set := &messages.SetProfile{Email: &email}
if req.Name != "" {
set.Name = &req.Name
}
if req.Phone != "" {
set.Phone = &req.Phone
}
if _, err := s.applier.Apply(r.Context(), id, set); err != nil {
writeErr(w, http.StatusInternalServerError, "could not create profile")
return
}
// Persist the credential only after the profile is created. If this fails
// the profile exists but is unreachable by login — acceptable and rare.
if err := s.store.Register(email, id, hash, time.Now().UTC().Format(time.RFC3339)); err != nil {
if err == ErrEmailExists {
writeErr(w, http.StatusConflict, "an account with this email already exists")
return
}
writeErr(w, http.StatusInternalServerError, "could not store credentials")
return
}
s.issueSession(w, r, id)
s.writeCustomer(w, r, http.StatusCreated, rawID.String(), id)
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
rec, ok := s.store.Get(req.Email)
// Always run a verify (even on miss, against the stored hash if present) and
// return a single generic error so the response does not reveal whether the
// email exists.
if !ok || !VerifyPassword(req.Password, rec.Hash) {
writeErr(w, http.StatusUnauthorized, "invalid email or password")
return
}
s.issueSession(w, r, rec.ProfileID)
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(rec.ProfileID).String(), rec.ProfileID)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
ClearSession(w, r)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
id, ok := s.requireSession(w, r)
if !ok {
return
}
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id)
}
func (s *Server) handleLinkCart(w http.ResponseWriter, r *http.Request) {
id, ok := s.requireSession(w, r)
if !ok {
return
}
var req linkCartRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
cartID, ok := parseBase62(req.CartID)
if !ok {
writeErr(w, http.StatusBadRequest, "invalid cartId")
return
}
if _, err := s.applier.Apply(r.Context(), id, &messages.LinkCart{CartId: cartID, Label: "current cart"}); err != nil {
writeErr(w, http.StatusInternalServerError, "could not link cart")
return
}
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id)
}
func (s *Server) handleLinkCheckout(w http.ResponseWriter, r *http.Request) {
id, ok := s.requireSession(w, r)
if !ok {
return
}
var req linkCheckoutRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
checkoutID, ok := parseBase62(req.CheckoutID)
if !ok {
writeErr(w, http.StatusBadRequest, "invalid checkoutId")
return
}
cartID, _ := parseBase62(req.CartID)
if _, err := s.applier.Apply(r.Context(), id, &messages.LinkCheckout{CheckoutId: checkoutID, CartId: cartID}); err != nil {
writeErr(w, http.StatusInternalServerError, "could not link checkout")
return
}
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id)
}
func (s *Server) handleLinkOrder(w http.ResponseWriter, r *http.Request) {
id, ok := s.requireSession(w, r)
if !ok {
return
}
var req linkOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
if strings.TrimSpace(req.OrderReference) == "" {
writeErr(w, http.StatusBadRequest, "orderReference is required")
return
}
cartID, _ := parseBase62(req.CartID)
if _, err := s.applier.Apply(r.Context(), id, &messages.LinkOrder{
OrderReference: req.OrderReference,
CartId: cartID,
Status: req.Status,
}); err != nil {
writeErr(w, http.StatusInternalServerError, "could not link order")
return
}
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id)
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func (s *Server) issueSession(w http.ResponseWriter, r *http.Request, id uint64) {
SetSession(w, r, s.signer.Issue(id, s.ttl), s.ttl)
}
// requireSession reads and validates the session cookie, writing 401 on
// failure. It returns the profile id and whether a valid session was present.
func (s *Server) requireSession(w http.ResponseWriter, r *http.Request) (uint64, bool) {
c, err := r.Cookie(SessionCookieName)
if err != nil || c.Value == "" {
writeErr(w, http.StatusUnauthorized, "not authenticated")
return 0, false
}
id, err := s.signer.Parse(c.Value)
if err != nil {
writeErr(w, http.StatusUnauthorized, "not authenticated")
return 0, false
}
return id, true
}
// writeCustomer loads the profile grain and writes it as a CustomerResponse.
func (s *Server) writeCustomer(w http.ResponseWriter, r *http.Request, status int, idStr string, id uint64) {
g, err := s.applier.Get(r.Context(), id)
if err != nil {
writeErr(w, http.StatusInternalServerError, "could not read profile")
return
}
writeJSON(w, status, grainToCustomer(idStr, g))
}
func grainToCustomer(id string, g *profile.ProfileGrain) CustomerResponse {
resp := CustomerResponse{
ID: id,
Name: g.Name,
Email: g.Email,
Phone: g.Phone,
Language: g.Language,
Currency: g.Currency,
AvatarURL: g.AvatarUrl,
Addresses: make([]AddressResponse, 0, len(g.Addresses)),
Orders: make([]OrderRef, 0, len(g.Orders)),
}
for _, a := range g.Addresses {
resp.Addresses = append(resp.Addresses, AddressResponse{
ID: a.Id,
Label: a.Label,
FullName: a.FullName,
AddressLine1: a.AddressLine1,
AddressLine2: a.AddressLine2,
City: a.City,
Zip: a.Zip,
Country: a.Country,
IsDefaultShipping: a.IsDefaultShipping,
IsDefaultBilling: a.IsDefaultBilling,
})
}
for _, o := range g.Orders {
resp.Orders = append(resp.Orders, OrderRef{OrderReference: o.OrderReference, Status: o.Status})
}
return resp
}
// parseBase62 decodes a base62 id (cart/checkout/profile share the scheme).
func parseBase62(s string) (uint64, bool) {
if s == "" {
return 0, false
}
id, ok := profile.ParseProfileId(s)
return uint64(id), ok
}
func validEmail(email string) bool {
if email == "" {
return false
}
_, err := mail.ParseAddress(email)
return err == nil
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
-123
View File
@@ -1,123 +0,0 @@
package customerauth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
// SessionCookieName is the name of the HttpOnly cookie that carries the signed
// session token for a logged-in customer.
const SessionCookieName = "sid"
// DefaultSessionTTL is how long an issued session stays valid.
const DefaultSessionTTL = 30 * 24 * time.Hour
var (
// ErrInvalidToken is returned when a token is malformed or its signature
// does not verify.
ErrInvalidToken = errors.New("customerauth: invalid session token")
// ErrExpiredToken is returned when a token's signature is valid but it has
// passed its expiry.
ErrExpiredToken = errors.New("customerauth: expired session token")
)
// Signer issues and verifies session tokens using an HMAC-SHA256 key.
type Signer struct {
secret []byte
}
// NewSigner returns a Signer over the given secret key bytes.
func NewSigner(secret []byte) *Signer { return &Signer{secret: secret} }
// token format: base64url(<profileID>.<expUnix>) "." base64url(hmac)
// The payload is signed, not encrypted — it carries no secrets, only the
// profile id and expiry, and the HMAC makes it tamper-evident.
// Issue returns a signed token for profileID that expires after ttl.
func (s *Signer) Issue(profileID uint64, ttl time.Duration) string {
exp := time.Now().Add(ttl).Unix()
payload := fmt.Sprintf("%d.%d", profileID, exp)
b := base64.RawURLEncoding.EncodeToString([]byte(payload))
return b + "." + s.sign(b)
}
// Parse verifies a token and returns the profile id it carries. It returns
// ErrInvalidToken for a bad signature/format and ErrExpiredToken when expired.
func (s *Signer) Parse(token string) (uint64, error) {
b, sig, ok := strings.Cut(token, ".")
if !ok || b == "" || sig == "" {
return 0, ErrInvalidToken
}
if !hmac.Equal([]byte(sig), []byte(s.sign(b))) {
return 0, ErrInvalidToken
}
raw, err := base64.RawURLEncoding.DecodeString(b)
if err != nil {
return 0, ErrInvalidToken
}
idStr, expStr, ok := strings.Cut(string(raw), ".")
if !ok {
return 0, ErrInvalidToken
}
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return 0, ErrInvalidToken
}
exp, err := strconv.ParseInt(expStr, 10, 64)
if err != nil {
return 0, ErrInvalidToken
}
if time.Now().Unix() >= exp {
return 0, ErrExpiredToken
}
return id, nil
}
func (s *Signer) sign(b string) string {
mac := hmac.New(sha256.New, s.secret)
mac.Write([]byte(b))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// SetSession writes the session cookie for token onto w. The Secure flag is set
// when the request arrived over TLS (directly or via an https-terminating
// proxy) so the cookie still works on plain-http localhost in dev.
func SetSession(w http.ResponseWriter, r *http.Request, token string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: isSecure(r),
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(ttl),
MaxAge: int(ttl.Seconds()),
})
}
// ClearSession expires the session cookie on w.
func ClearSession(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: isSecure(r),
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
func isSecure(r *http.Request) bool {
if r.TLS != nil {
return true
}
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
-119
View File
@@ -1,119 +0,0 @@
package customerauth
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
)
// ErrEmailExists is returned by Register when the (normalized) email is already
// associated with a credential record.
var ErrEmailExists = errors.New("customerauth: email already registered")
// Record is a single stored credential: which profile an email belongs to and
// its password hash. It deliberately does not embed any profile data — the
// profile grain remains the source of truth for that.
type Record struct {
Email string `json:"email"`
ProfileID uint64 `json:"profileId"`
Hash string `json:"hash"`
CreatedAt string `json:"createdAt,omitempty"`
}
// CredentialStore is a small email→credential index persisted to a JSON file.
// It is the email lookup that the event-sourced profile grains intentionally
// lack. Concurrency-safe; writes are atomic (temp file + rename).
type CredentialStore struct {
mu sync.RWMutex
path string
byMail map[string]Record // key: normalized email
}
// LoadCredentialStore opens (or initializes) the store backed by path. A
// missing file is treated as an empty store; the file is created on first write.
func LoadCredentialStore(path string) (*CredentialStore, error) {
s := &CredentialStore{path: path, byMail: make(map[string]Record)}
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return s, nil
}
return nil, fmt.Errorf("customerauth: read %s: %w", path, err)
}
var records []Record
if len(data) > 0 {
if err := json.Unmarshal(data, &records); err != nil {
return nil, fmt.Errorf("customerauth: parse %s: %w", path, err)
}
}
for _, r := range records {
s.byMail[NormalizeEmail(r.Email)] = r
}
return s, nil
}
// NormalizeEmail lower-cases and trims an email for use as a lookup key.
func NormalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
// Get returns the record for email and whether it exists.
func (s *CredentialStore) Get(email string) (Record, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
r, ok := s.byMail[NormalizeEmail(email)]
return r, ok
}
// Register adds a new credential record and persists the store. It returns
// ErrEmailExists if the email is already taken.
func (s *CredentialStore) Register(email string, profileID uint64, hash, createdAt string) error {
key := NormalizeEmail(email)
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.byMail[key]; exists {
return ErrEmailExists
}
s.byMail[key] = Record{Email: key, ProfileID: profileID, Hash: hash, CreatedAt: createdAt}
return s.persistLocked()
}
// persistLocked writes the whole store to disk atomically. Caller holds s.mu.
func (s *CredentialStore) persistLocked() error {
records := make([]Record, 0, len(s.byMail))
for _, r := range s.byMail {
records = append(records, r)
}
data, err := json.MarshalIndent(records, "", " ")
if err != nil {
return fmt.Errorf("customerauth: marshal store: %w", err)
}
if dir := filepath.Dir(s.path); dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("customerauth: mkdir %s: %w", dir, err)
}
}
tmp, err := os.CreateTemp(filepath.Dir(s.path), ".credentials-*.tmp")
if err != nil {
return fmt.Errorf("customerauth: temp file: %w", err)
}
tmpName := tmp.Name()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
os.Remove(tmpName)
return fmt.Errorf("customerauth: write temp: %w", err)
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return fmt.Errorf("customerauth: close temp: %w", err)
}
if err := os.Rename(tmpName, s.path); err != nil {
os.Remove(tmpName)
return fmt.Errorf("customerauth: rename temp: %w", err)
}
return nil
}
-151
View File
@@ -1,151 +0,0 @@
package ucp
import (
"context"
"net/http"
"strings"
)
// ---------------------------------------------------------------------------
// UCP-Agent header parsing (RFC 8941 Dictionary Structured Field)
// ---------------------------------------------------------------------------
// ucpAgentProfileKey is the context key for the UCP-Agent profile URI.
type ucpAgentProfileKey struct{}
// UCPAgentProfileFromContext returns the UCP-Agent profile URI stored in the
// request context, or empty string if none was advertised.
func UCPAgentProfileFromContext(ctx context.Context) string {
v, _ := ctx.Value(ucpAgentProfileKey{}).(string)
return v
}
// parseUCPAgentProfile parses a single "profile" member value from an RFC 8941
// Dictionary Structured Field header. It only handles the string-value form:
//
// UCP-Agent: profile="https://agent.example/profiles/shopping-agent.json"
//
// Returns the profile URI (without surrounding quotes) or empty string.
func parseUCPAgentProfile(header string) string {
if header == "" {
return ""
}
// Walk the dictionary entries looking for "profile".
// RFC 8941 §3.1.2: Dict = ((im-key | key) value) *(SP "," ((im-key | key) value))
i := 0
bs := []byte(header)
n := len(bs)
for i < n {
// Skip whitespace.
for i < n && (bs[i] == ' ' || bs[i] == '\t') {
i++
}
if i >= n {
break
}
// Read key (im-key or key). Lowercase letters, digits, "_", "-", "*".
start := i
for i < n && isKeyChar(bs[i]) {
i++
}
if i == start {
// No key found; skip to next comma or end.
for i < n && bs[i] != ',' {
i++
}
i++ // skip comma
continue
}
key := string(bs[start:i])
// Check for "=" separator. RFC 8941 allows bare names (boolean true)
// when "=? " follows — but we only care about key=value entries.
if i < n && bs[i] == '=' {
i++ // skip '='
// Read the value. For our case it's a string (starts with '"').
if i < n && bs[i] == '"' {
i++ // skip opening quote
valStart := i
for i < n && bs[i] != '"' {
if bs[i] == '\\' {
i++ // skip escape
}
i++
}
val := string(bs[valStart:i])
if i < n {
i++ // skip closing quote
}
if strings.EqualFold(key, "profile") {
return val
}
}
}
// Skip to next comma or end.
for i < n && bs[i] != ',' {
i++
}
i++ // skip comma
}
return ""
}
// isKeyChar reports whether b is valid in an RFC 8941 key (lc-ltr, DIGIT, "_", "-", "*").
func isKeyChar(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') || b == '_' || b == '-' || b == '*'
}
// WithUCPAgent is HTTP middleware that reads the UCP-Agent header from incoming
// requests, parses it per RFC 8941, and stores the profile URI in the request
// context so downstream handlers can inspect the calling platform's identity.
//
// Usage:
//
// mux.Handle("/ucp/v1/carts", ucp.WithUCPAgent(ucp.CartHandler(pool)))
func WithUCPAgent(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if profile := parseUCPAgentProfile(r.Header.Get("UCP-Agent")); profile != "" {
ctx := context.WithValue(r.Context(), ucpAgentProfileKey{}, profile)
r = r.WithContext(ctx)
}
next.ServeHTTP(w, r)
})
}
// DefaultUCPAgentProfile is the profile URI sent on outgoing requests from
// this commerce platform. Override via SetDefaultUCPAgentProfile or the
// UCP_AGENT_PROFILE environment variable at init time.
var DefaultUCPAgentProfile = "https://cart.k6n.net/.well-known/ucp"
// SetDefaultUCPAgentProfile overrides the default profile URI used for outgoing
// UCP-Agent headers.
func SetDefaultUCPAgentProfile(profile string) {
if profile != "" {
DefaultUCPAgentProfile = profile
}
}
// UCPAgentHeaderValue returns the RFC 8941 Dictionary value for the UCP-Agent
// header, encoding the given profile URI.
func UCPAgentHeaderValue(profile string) string {
if profile == "" {
profile = DefaultUCPAgentProfile
}
return `profile="` + profile + `"`
}
// WithUCPAgentOnRequest adds the UCP-Agent header to an outgoing HTTP request.
// It sets the header to the default profile unless a custom profile is provided.
func WithUCPAgentOnRequest(req *http.Request, profile ...string) {
p := DefaultUCPAgentProfile
if len(profile) > 0 && profile[0] != "" {
p = profile[0]
}
req.Header.Set("UCP-Agent", `profile="`+p+`"`)
}
-338
View File
@@ -1,338 +0,0 @@
package ucp
import (
"context"
"encoding/json"
"log"
"net/http"
"sync"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/profile"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
"google.golang.org/protobuf/proto"
)
// CartServer is the UCP REST adapter over the cart grain pool.
type CartServer struct {
applier CartApplier
profileApplier ProfileApplier // optional; when set, links carts to customer profiles
newCartId func() (cart.CartId, error)
}
// NewCartServer builds a UCP REST adapter over a cart grain pool.
func NewCartServer(applier CartApplier) *CartServer {
return &CartServer{
applier: applier,
newCartId: cart.NewCartId,
}
}
// SetProfileApplier enables identity linking on this server.
func (s *CartServer) SetProfileApplier(pa ProfileApplier) {
s.profileApplier = pa
}
// ---------------------------------------------------------------------------
// UCP Cart endpoints
// ---------------------------------------------------------------------------
// handleCreateCart handles POST /carts.
func (s *CartServer) handleCreateCart(w http.ResponseWriter, r *http.Request) {
cid, err := s.newCartId()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate cart id")
return
}
id := uint64(cid)
// Seed the cart grain by calling Get once (spawns if new).
if _, err := s.applier.Get(r.Context(), id); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create cart: "+err.Error())
return
}
// If items were supplied, add them.
var req CreateCartRequest
hasBody := false
if r.ContentLength > 0 {
if err := json.NewDecoder(r.Body).Decode(&req); err == nil {
hasBody = true
}
}
if hasBody && len(req.Items) > 0 {
groups := buildItemGroups(r.Context(), req.Items, "")
if _, err := applyItemGroups(r.Context(), s.applier, id, groups); err != nil {
writeError(w, http.StatusInternalServerError, "failed to add items: "+err.Error())
return
}
}
g, err := s.applier.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read cart: "+err.Error())
return
}
writeJSON(w, http.StatusCreated, cartGrainToResponse(cid.String(), g))
}
// handleGetCart handles GET /carts/{id}.
func (s *CartServer) handleGetCart(w http.ResponseWriter, r *http.Request) {
id, ok := parseCartID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid cart id")
return
}
g, err := s.applier.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "cart not found")
return
}
writeJSON(w, http.StatusOK, cartGrainToResponse(r.PathValue("id"), g))
}
// handleUpdateCart handles PUT /carts/{id}.
func (s *CartServer) handleUpdateCart(w http.ResponseWriter, r *http.Request) {
id, ok := parseCartID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid cart id")
return
}
var req UpdateCartRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
// Clear first, then apply.
if _, err := s.applier.Apply(r.Context(), id, &messages.ClearCartRequest{}); err != nil {
writeError(w, http.StatusInternalServerError, "failed to clear cart: "+err.Error())
return
}
// Add items.
if len(req.Items) > 0 {
groups := buildItemGroups(r.Context(), req.Items, req.Country)
if _, err := applyItemGroups(r.Context(), s.applier, id, groups); err != nil {
writeError(w, http.StatusInternalServerError, "failed to add items: "+err.Error())
return
}
}
// Set user ID and auto-link cart to customer profile.
if req.UserId != nil && *req.UserId != "" {
if _, err := s.applier.Apply(r.Context(), id, &messages.SetUserId{UserId: *req.UserId}); err != nil {
writeError(w, http.StatusInternalServerError, "failed to set user: "+err.Error())
return
}
// Auto-link this cart to the customer's profile if identity linking is enabled.
if s.profileApplier != nil {
if pid, ok := profile.ParseProfileId(*req.UserId); ok {
if err := linkCartToProfile(s.profileApplier, r.Context(), uint64(pid), id); err != nil {
log.Printf("identity: failed to link cart %d to profile %d: %v", id, uint64(pid), err)
}
}
}
}
g, err := s.applier.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read cart: "+err.Error())
return
}
writeJSON(w, http.StatusOK, cartGrainToResponse(r.PathValue("id"), g))
}
// handleCancelCart handles POST /carts/{id}/cancel.
func (s *CartServer) handleCancelCart(w http.ResponseWriter, r *http.Request) {
id, ok := parseCartID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid cart id")
return
}
if _, err := s.applier.Apply(r.Context(), id, &messages.ClearCartRequest{}); err != nil {
writeError(w, http.StatusInternalServerError, "failed to clear cart: "+err.Error())
return
}
g, err := s.applier.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read cart: "+err.Error())
return
}
writeJSON(w, http.StatusOK, cartGrainToResponse(r.PathValue("id"), g))
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func parseCartID(r *http.Request) (uint64, bool) {
raw := r.PathValue("id")
if raw == "" {
return 0, false
}
cid, ok := cart.ParseCartId(raw)
if !ok {
return 0, false
}
return uint64(cid), true
}
func cartGrainToResponse(id string, g *cart.CartGrain) CartResponse {
resp := CartResponse{
Id: id,
Items: make([]CartItem, 0, len(g.Items)),
Totals: Totals{Currency: g.Currency},
Status: "active",
}
if g.CheckoutStatus != nil && *g.CheckoutStatus != "" {
resp.Status = string(*g.CheckoutStatus)
}
for _, it := range g.Items {
if it == nil {
continue
}
item := CartItem{
Sku: it.Sku,
Quantity: int(it.Quantity),
UnitPrice: it.Price.IncVat,
TotalPrice: it.TotalPrice.IncVat,
TaxRate: it.Tax,
ItemId: it.Id,
Fields: it.CustomFields,
}
if it.Meta != nil {
item.Name = it.Meta.Name
item.Image = it.Meta.Image
}
resp.Items = append(resp.Items, item)
}
if g.TotalPrice != nil {
resp.Totals.TotalIncVat = g.TotalPrice.IncVat
resp.Totals.TotalExVat = g.TotalPrice.ValueExVat()
resp.Totals.TotalVat = g.TotalPrice.TotalVat()
}
if g.TotalDiscount != nil {
resp.Totals.Discount = g.TotalDiscount.IncVat
}
return resp
}
// ---------------------------------------------------------------------------
// Item group building (adapted from cmd/cart/pool-server.go)
// ---------------------------------------------------------------------------
type itemGroup struct {
parent *messages.AddItem
children []*messages.AddItem
}
func buildItemGroups(_ context.Context, items []CartItemInput, _ string) []itemGroup {
groups := make([]itemGroup, len(items))
var mu sync.Mutex
var wg sync.WaitGroup
for i, itm := range items {
wg.Add(1)
go func(i int, itm CartItemInput) {
defer wg.Done()
parentMsg := buildAddItem(itm, 0)
mu.Lock()
groups[i].parent = parentMsg
mu.Unlock()
if len(itm.Children) > 0 {
children := make([]*messages.AddItem, len(itm.Children))
for j, child := range itm.Children {
children[j] = buildAddItem(child, parentMsg.ItemId)
}
mu.Lock()
groups[i].children = children
mu.Unlock()
}
}(i, itm)
}
wg.Wait()
return groups
}
func buildAddItem(input CartItemInput, parentItemId uint32) *messages.AddItem {
qty := int32(input.Quantity)
if qty <= 0 {
qty = 1
}
msg := &messages.AddItem{
Sku: input.Sku,
Quantity: qty,
}
if input.StoreId != nil {
msg.StoreId = input.StoreId
}
if input.Sku != "" {
// Pass along the item id so findParentLineId can match.
msg.ItemId = parentItemId
}
if len(input.Fields) > 0 {
msg.CustomFields = input.Fields
}
if parentItemId > 0 {
msg.ParentId = &parentItemId
}
return msg
}
func applyItemGroups(ctx context.Context, applier CartApplier, id uint64, groups []itemGroup) (*actor.MutationResult[cart.CartGrain], error) {
var last *actor.MutationResult[cart.CartGrain]
for _, g := range groups {
if g.parent == nil {
continue
}
res, err := applier.Apply(ctx, id, g.parent)
if err != nil {
return nil, err
}
last = res
if len(g.children) == 0 {
continue
}
parentLineId, ok := findParentLineId(&res.Result, g.parent)
if !ok {
continue
}
childMsgs := make([]proto.Message, len(g.children))
for i, c := range g.children {
c.ParentId = &parentLineId
childMsgs[i] = c
}
res, err = applier.Apply(ctx, id, childMsgs...)
if err != nil {
return nil, err
}
last = res
}
return last, nil
}
func findParentLineId(grain *cart.CartGrain, parent *messages.AddItem) (uint32, bool) {
for _, it := range grain.Items {
if it == nil || it.ParentId != nil {
continue
}
if it.ItemId != parent.ItemId {
continue
}
sameStore := (it.StoreId == nil && parent.StoreId == nil) ||
(it.StoreId != nil && parent.StoreId != nil && *it.StoreId == *parent.StoreId)
if sameStore {
return it.Id, true
}
}
return 0, false
}
-236
View File
@@ -1,236 +0,0 @@
package ucp
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
"google.golang.org/protobuf/proto"
)
// testApplier implements CartApplier with an in-memory map of grains.
type testApplier struct {
grains map[uint64]*cart.CartGrain
}
func newTestApplier() *testApplier {
return &testApplier{
grains: make(map[uint64]*cart.CartGrain),
}
}
func (a *testApplier) Get(_ context.Context, id uint64) (*cart.CartGrain, error) {
g, ok := a.grains[id]
if !ok {
g = cart.NewCartGrain(id, time.UnixMilli(1000000))
a.grains[id] = g
}
return g, nil
}
func (a *testApplier) Apply(_ context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[cart.CartGrain], error) {
g, err := a.Get(nil, id)
if err != nil {
return nil, err
}
results := make([]actor.ApplyResult, len(msgs))
for i, msg := range msgs {
results[i] = actor.ApplyResult{Type: string(msg.ProtoReflect().Descriptor().FullName())}
switch m := msg.(type) {
case *messages.ClearCartRequest:
g.Items = nil
g.Vouchers = nil
g.TotalDiscount = cart.NewPrice()
g.TotalPrice = cart.NewPrice()
case *messages.SetUserId:
// userId is unexported; rely on serialization to verify.
_ = m
}
}
return &actor.MutationResult[cart.CartGrain]{
Result: *g,
Mutations: results,
}, nil
}
// mustParseID returns the uint64 representation of a base62 cart id string.
func mustParseID(s string) uint64 {
id, _ := cart.ParseCartId(s)
return uint64(id)
}
func TestCartHandler_CreateCart(t *testing.T) {
applier := newTestApplier()
handler := CartHandler(applier)
req := httptest.NewRequest("POST", "/", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created, got %d", rec.Code)
}
var resp CartResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Id == "" {
t.Fatal("expected non-empty cart id")
}
if resp.Status != "active" {
t.Fatalf("expected status 'active', got %q", resp.Status)
}
}
func TestCartHandler_GetCartNotFound(t *testing.T) {
applier := newTestApplier()
handler := CartHandler(applier)
req := httptest.NewRequest("GET", "/nonexistent", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
// /nonexistent is a valid base62 string, so it parses, but the cart
// auto-creates on first Get, so we get a 200 with an empty cart.
// This is expected behavior (UCP auto-creates carts on read).
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 (auto-create), got %d", rec.Code)
}
}
func TestCartHandler_CreateAndGet(t *testing.T) {
applier := newTestApplier()
handler := CartHandler(applier)
// Create a cart.
req := httptest.NewRequest("POST", "/", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create failed: %d", rec.Code)
}
var created CartResponse
json.NewDecoder(rec.Body).Decode(&created)
// GET it back.
req = httptest.NewRequest("GET", "/"+created.Id, nil)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var got CartResponse
json.NewDecoder(rec.Body).Decode(&got)
if got.Id != created.Id {
t.Fatalf("expected id %q, got %q", created.Id, got.Id)
}
}
func TestCartHandler_CancelCart(t *testing.T) {
applier := newTestApplier()
handler := CartHandler(applier)
// Create a cart.
req := httptest.NewRequest("POST", "/", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
var created CartResponse
json.NewDecoder(rec.Body).Decode(&created)
// Cancel it.
req = httptest.NewRequest("POST", "/"+created.Id+"/cancel", nil)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var cancelled CartResponse
json.NewDecoder(rec.Body).Decode(&cancelled)
if cancelled.Id != created.Id {
t.Fatalf("expected same id %q, got %q", created.Id, cancelled.Id)
}
}
func TestCartHandler_UpdateCart(t *testing.T) {
applier := newTestApplier()
handler := CartHandler(applier)
// Create a cart.
req := httptest.NewRequest("POST", "/", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
var created CartResponse
json.NewDecoder(rec.Body).Decode(&created)
// Update with items (test applier doesn't process AddItem, but the
// handler still returns 200 — items are populated by the real grain pool).
updateBody := `{"items": [{"sku": "test-sku", "quantity": 2, "name": "Test Item"}]}`
req = httptest.NewRequest("PUT", "/"+created.Id, strings.NewReader(updateBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var updated CartResponse
json.NewDecoder(rec.Body).Decode(&updated)
if updated.Id != created.Id {
t.Fatalf("expected same id %q, got %q", created.Id, updated.Id)
}
}
func TestCartResponse_Conversion(t *testing.T) {
id := mustParseID("ABCD")
g := cart.NewCartGrain(id, time.UnixMilli(1000000))
g.Currency = "SEK"
g.Items = append(g.Items, &cart.CartItem{
Sku: "test-1",
Quantity: 2,
Price: *mustPrice(10000, 2500),
TotalPrice: *mustPrice(20000, 5000),
Tax: 2500,
Meta: &cart.ItemMeta{Name: "Test Item"},
})
g.TotalPrice = mustPrice(20000, 5000)
resp := cartGrainToResponse(g.Id.String(), g)
if len(resp.Items) != 1 {
t.Fatalf("expected 1 item, got %d", len(resp.Items))
}
if resp.Items[0].Sku != "test-1" {
t.Fatalf("expected sku 'test-1', got %q", resp.Items[0].Sku)
}
if resp.Items[0].Name != "Test Item" {
t.Fatalf("expected name 'Test Item', got %q", resp.Items[0].Name)
}
if resp.Totals.TotalIncVat != 20000 {
t.Fatalf("expected total 20000, got %d", resp.Totals.TotalIncVat)
}
if resp.Totals.Currency != "SEK" {
t.Fatalf("expected currency SEK, got %q", resp.Totals.Currency)
}
}
func mustPrice(incVat int64, totalVat int64) *cart.Price {
return &cart.Price{
IncVat: incVat,
VatRates: map[float32]int64{25: totalVat},
}
}
-681
View File
@@ -1,681 +0,0 @@
package ucp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
profilePkg "git.k6n.net/mats/go-cart-actor/pkg/profile"
checkoutMessages "git.k6n.net/mats/go-cart-actor/proto/checkout"
"google.golang.org/protobuf/types/known/anypb"
"google.golang.org/protobuf/types/known/timestamppb"
)
// CheckoutServer is the UCP REST adapter over the checkout grain pool.
type CheckoutServer struct {
applier CheckoutApplier
profileApplier ProfileApplier // optional; when set links checkouts/orders to profiles
orderSvc OrderApplier // optional; when set creates real orders on complete
}
// NewCheckoutServer builds a UCP REST adapter over a checkout grain pool.
func NewCheckoutServer(applier CheckoutApplier, orderSvc ...OrderApplier) *CheckoutServer {
s := &CheckoutServer{applier: applier}
if len(orderSvc) > 0 {
s.orderSvc = orderSvc[0]
}
return s
}
// SetProfileApplier enables identity linking on this server.
func (s *CheckoutServer) SetProfileApplier(pa ProfileApplier) {
s.profileApplier = pa
}
// ---------------------------------------------------------------------------
// UCP Checkout endpoints
// ---------------------------------------------------------------------------
// handleCreateCheckout handles POST /checkout-sessions.
//
// Initiates a checkout session from a cart. The cart id is required.
func (s *CheckoutServer) handleCreateCheckout(w http.ResponseWriter, r *http.Request) {
var req CreateCheckoutRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
items := req.Items
if len(items) == 0 && len(req.LineItems) > 0 {
items = req.LineItems
}
var cartIdStr string
if req.CartId != "" {
cartIdStr = req.CartId
} else if len(items) > 0 {
// Fetch cart base URL
cartBaseURL := os.Getenv("CART_INTERNAL_URL")
if cartBaseURL == "" {
cartBaseURL = "http://cart:8080"
}
// Prepare POST request to /ucp/v1/carts/ to create and seed the cart
createReqBody := CreateCartRequest{
Items: items,
}
bodyBytes, err := json.Marshal(createReqBody)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to marshal create cart request: "+err.Error())
return
}
client := &http.Client{Timeout: 10 * time.Second}
cartReq, err := http.NewRequestWithContext(r.Context(), "POST", fmt.Sprintf("%s/ucp/v1/carts/", cartBaseURL), bytes.NewReader(bodyBytes))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create post cart request: "+err.Error())
return
}
cartReq.Header.Set("Content-Type", "application/json")
profile := os.Getenv("UCP_AGENT_PROFILE")
if profile == "" {
profile = DefaultUCPAgentProfile
}
cartReq.Header.Set("UCP-Agent", `profile="`+profile+`"`)
cartResp, err := client.Do(cartReq)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create implicit cart: "+err.Error())
return
}
defer cartResp.Body.Close()
if cartResp.StatusCode != http.StatusCreated && cartResp.StatusCode != http.StatusOK {
writeError(w, http.StatusBadRequest, fmt.Sprintf("cart service returned status: %d when creating implicit cart", cartResp.StatusCode))
return
}
var cartRespBody CartResponse
if err := json.NewDecoder(cartResp.Body).Decode(&cartRespBody); err != nil {
writeError(w, http.StatusInternalServerError, "failed to decode implicit cart response: "+err.Error())
return
}
cartIdStr = cartRespBody.Id
} else {
writeError(w, http.StatusBadRequest, "cartId or items/line_items is required")
return
}
cartId, ok := cart.ParseCartId(cartIdStr)
if !ok {
writeError(w, http.StatusBadRequest, "invalid cartId")
return
}
// Create a new checkout session id.
checkoutId, err := cart.NewCartId()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate checkout id")
return
}
// Fetch cart state from cart service
cartBaseURL := os.Getenv("CART_INTERNAL_URL")
if cartBaseURL == "" {
cartBaseURL = "http://cart:8080"
}
url := fmt.Sprintf("%s/cart/byid/%s", cartBaseURL, cartId.String())
client := &http.Client{Timeout: 10 * time.Second}
cartReq, err := http.NewRequestWithContext(r.Context(), "GET", url, nil)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create cart request: "+err.Error())
return
}
profile := os.Getenv("UCP_AGENT_PROFILE")
if profile == "" {
profile = DefaultUCPAgentProfile
}
cartReq.Header.Set("UCP-Agent", `profile="`+profile+`"`)
cartResp, err := client.Do(cartReq)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to fetch cart: "+err.Error())
return
}
defer cartResp.Body.Close()
if cartResp.StatusCode != http.StatusOK {
writeError(w, http.StatusBadRequest, fmt.Sprintf("cart service returned status: %d", cartResp.StatusCode))
return
}
cartStateBytes, err := io.ReadAll(cartResp.Body)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read cart state: "+err.Error())
return
}
// Initialize checkout with cart reference and state.
initMsg := &checkoutMessages.InitializeCheckout{
OrderId: "",
CartId: uint64(cartId),
CartState: &anypb.Any{
TypeUrl: "type.googleapis.com/cart.CartGrain",
Value: cartStateBytes,
},
}
if _, err := s.applier.Apply(r.Context(), uint64(checkoutId), initMsg); err != nil {
writeError(w, http.StatusInternalServerError, "failed to initialize checkout: "+err.Error())
return
}
g, err := s.applier.Get(r.Context(), uint64(checkoutId))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read checkout: "+err.Error())
return
}
// Link checkout to customer profile if identity linking is enabled.
if s.profileApplier != nil && req.CustomerId != "" {
if pid, ok := profilePkg.ParseProfileId(req.CustomerId); ok {
if err := linkCheckoutToProfile(s.profileApplier, r.Context(), uint64(pid), uint64(checkoutId), uint64(cartId)); err != nil {
log.Printf("identity: failed to link checkout %d to profile %s: %v", uint64(checkoutId), req.CustomerId, err)
}
}
}
writeJSON(w, http.StatusCreated, checkoutGrainToResponse(checkoutId.String(), g))
}
// handleGetCheckout handles GET /checkout-sessions/{id}.
func (s *CheckoutServer) handleGetCheckout(w http.ResponseWriter, r *http.Request) {
id, ok := parseSessionID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid checkout session id")
return
}
g, err := s.applier.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "checkout session not found")
return
}
writeJSON(w, http.StatusOK, checkoutGrainToResponse(r.PathValue("id"), g))
}
// handleUpdateCheckout handles PUT /checkout-sessions/{id}.
//
// Updates checkout with delivery, contact, or pickup point info.
func (s *CheckoutServer) handleUpdateCheckout(w http.ResponseWriter, r *http.Request) {
id, ok := parseSessionID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid checkout session id")
return
}
var req UpdateCheckoutRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
// Apply delivery.
if req.Delivery != nil {
msg := &checkoutMessages.SetDelivery{
Provider: req.Delivery.Provider,
}
for _, itemId := range req.Delivery.ItemIds {
msg.Items = append(msg.Items, itemId)
}
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
writeError(w, http.StatusInternalServerError, "failed to set delivery: "+err.Error())
return
}
}
// Apply pickup point.
if req.PickupPoint != nil {
pp := &checkoutMessages.SetPickupPoint{
DeliveryId: req.PickupPoint.DeliveryId,
PickupPoint: &checkoutMessages.PickupPoint{
Id: req.PickupPoint.Id,
Name: req.PickupPoint.Name,
},
}
if _, err := s.applier.Apply(r.Context(), id, pp); err != nil {
writeError(w, http.StatusInternalServerError, "failed to set pickup point: "+err.Error())
return
}
}
// Apply contact details.
if req.Contact != nil {
msg := &checkoutMessages.ContactDetailsUpdated{
Email: req.Contact.Email,
Phone: req.Contact.Phone,
Name: req.Contact.Name,
PostalCode: req.Contact.PostalCode,
}
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update contact details: "+err.Error())
return
}
}
g, err := s.applier.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read checkout: "+err.Error())
return
}
writeJSON(w, http.StatusOK, checkoutGrainToResponse(r.PathValue("id"), g))
}
// handleCompleteCheckout handles POST /checkout-sessions/{id}/complete.
//
// Completes the checkout session and creates an order. When an OrderApplier
// is configured on the server, a real order is created by calling the order
// service. Otherwise, a simplified order reference is generated locally.
func (s *CheckoutServer) handleCompleteCheckout(w http.ResponseWriter, r *http.Request) {
id, ok := parseSessionID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid checkout session id")
return
}
var req CompleteCheckoutRequest
json.NewDecoder(r.Body).Decode(&req) // best-effort
g, err := s.applier.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "checkout session not found")
return
}
// Default currency/country from the request or fall back to cart/cart state.
currency := req.Currency
country := req.Country
if currency == "" && g.CartState != nil {
currency = g.CartState.Currency
}
if currency == "" {
currency = "SEK"
}
var orderID string
if s.orderSvc != nil {
// Real order creation via the configured OrderApplier.
orderID, err = s.orderSvc.CreateOrder(r.Context(), id, g, req.Provider, req.PaymentToken, currency, country)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create order: "+err.Error())
return
}
} else {
// Fallback: generate a local order reference (simplified).
var ref cart.CartId
ref, err = cart.NewCartId()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate order reference")
return
}
orderID = ref.String()
}
// Record the order on the checkout grain.
if _, err := s.applier.Apply(r.Context(), id,
&checkoutMessages.OrderCreated{
OrderId: orderID,
CreatedAt: timestamppb.New(time.Now()),
},
); err != nil {
writeError(w, http.StatusInternalServerError, "failed to record order: "+err.Error())
return
}
g, err = s.applier.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read checkout: "+err.Error())
return
}
// Auto-link order to customer profile if identity linking is enabled.
if s.profileApplier != nil && orderID != "" && req.CustomerId != "" {
if pid, ok := profilePkg.ParseProfileId(req.CustomerId); ok {
if err := linkOrderToProfile(s.profileApplier, r.Context(), uint64(pid), orderID, uint64(g.CartId), "placed"); err != nil {
log.Printf("identity: failed to link order %s to profile %s: %v", orderID, req.CustomerId, err)
}
}
}
writeJSON(w, http.StatusOK, checkoutGrainToResponse(r.PathValue("id"), g))
}
// handleCancelCheckout handles POST /checkout-sessions/{id}/cancel.
//
// Cancels the checkout session.
func (s *CheckoutServer) handleCancelCheckout(w http.ResponseWriter, r *http.Request) {
id, ok := parseSessionID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid checkout session id")
return
}
// Cancel all active payments.
for {
g, err := s.applier.Get(r.Context(), id)
if err != nil {
break
}
openPayments := g.OpenPayments()
if len(openPayments) == 0 {
break
}
for _, p := range openPayments {
s.applier.Apply(r.Context(), id, &checkoutMessages.CancelPayment{
PaymentId: p.PaymentId,
CancelledAt: timestamppb.New(time.Now()),
})
}
}
g, err := s.applier.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "checkout session not found")
return
}
writeJSON(w, http.StatusOK, checkoutGrainToResponse(r.PathValue("id"), g))
}
// ---------------------------------------------------------------------------
// OrderHTTPClient — an OrderApplier backed by the order service HTTP API
// ---------------------------------------------------------------------------
// OrderHTTPClient implements OrderApplier by POSTing to an order service's
// /api/orders/from-checkout endpoint. It mirrors the existing -> order handoff
// used by the checkout service's own Klarna/Adyen callbacks.
type OrderHTTPClient struct {
baseURL string
httpClient *http.Client
agentProfile string // UCP-Agent profile URI
}
// NewOrderHTTPClient returns an order applier that creates orders by calling
// the order service at baseURL (e.g. "http://order-service:8092"). It reads
// the UCP_AGENT_PROFILE env var for the profile URI, falling back to
// DefaultUCPAgentProfile from the ucp package.
func NewOrderHTTPClient(baseURL string) *OrderHTTPClient {
profile := os.Getenv("UCP_AGENT_PROFILE")
if profile == "" {
profile = DefaultUCPAgentProfile
}
return &OrderHTTPClient{
baseURL: baseURL,
httpClient: &http.Client{Timeout: 30 * time.Second},
agentProfile: profile,
}
}
// fromCheckoutReq mirrors cmd/order/handlers_checkout.go's internal type.
type fromCheckoutReq struct {
CheckoutId string `json:"checkoutId"`
IdempotencyKey string `json:"idempotencyKey"`
CartId string `json:"cartId"`
Currency string `json:"currency"`
Country string `json:"country"`
CustomerEmail string `json:"customerEmail,omitempty"`
CustomerName string `json:"customerName,omitempty"`
Lines []orderLine `json:"lines"`
Payment orderPayment `json:"payment"`
}
type orderLine struct {
Reference string `json:"reference"`
Sku string `json:"sku"`
Name string `json:"name"`
Quantity int32 `json:"quantity"`
UnitPrice int64 `json:"unitPrice"`
TaxRate int32 `json:"taxRate"`
}
type orderPayment struct {
Provider string `json:"provider"`
Reference string `json:"reference"`
Amount int64 `json:"amount"`
}
// CreateOrder implements OrderApplier. It builds a from-checkout request from
// the checkout grain's state and POSTs it to the order service.
func (c *OrderHTTPClient) CreateOrder(ctx context.Context, checkoutID uint64, g *checkout.CheckoutGrain, provider, reference, currency, country string) (string, error) {
if g.CartState == nil {
return "", fmt.Errorf("order: checkout %d has no cart state", checkoutID)
}
var customerEmail, customerName string
if g.ContactDetails != nil {
if g.ContactDetails.Email != nil {
customerEmail = *g.ContactDetails.Email
}
if g.ContactDetails.Name != nil {
customerName = *g.ContactDetails.Name
}
}
// Compute total from cart items + deliveries.
var totalAmount int64
lines := buildOrderLines(g)
for _, l := range lines {
totalAmount += l.UnitPrice * int64(l.Quantity)
}
req := fromCheckoutReq{
CheckoutId: fmt.Sprintf("%d", checkoutID),
IdempotencyKey: fmt.Sprintf("ucp-checkout-%d", checkoutID),
CartId: g.CartId.String(),
Currency: currency,
Country: country,
CustomerEmail: customerEmail,
CustomerName: customerName,
Lines: lines,
Payment: orderPayment{
Provider: provider,
Reference: reference,
Amount: totalAmount,
},
}
body, err := json.Marshal(req)
if err != nil {
return "", fmt.Errorf("order: marshal: %w", err)
}
url := c.baseURL + "/api/orders/from-checkout"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("order: new request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
// Advertise this platform's UCP profile on all outgoing requests.
WithUCPAgentOnRequest(httpReq, c.agentProfile)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return "", fmt.Errorf("order: do: %w", err)
}
defer resp.Body.Close()
// Both 201 and 409 (idempotent hit) are valid.
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusConflict {
return "", fmt.Errorf("order: %s", resp.Status)
}
var result struct {
OrderId string `json:"orderId"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("order: decode: %w", err)
}
if result.OrderId == "" {
return "", fmt.Errorf("order: empty orderId in response")
}
return result.OrderId, nil
}
// buildOrderLines converts a checkout grain's cart items and delivery selections
// into order lines, matching the format expected by the order service.
func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
lines := make([]orderLine, 0, len(g.CartState.Items)+len(g.Deliveries))
for _, it := range g.CartState.Items {
if it == nil {
continue
}
name := ""
if it.Meta != nil {
name = it.Meta.Name
}
lines = append(lines, orderLine{
Reference: it.Sku,
Sku: it.Sku,
Name: name,
Quantity: int32(it.Quantity),
UnitPrice: it.Price.IncVat,
TaxRate: int32(it.Tax),
})
}
for _, d := range g.Deliveries {
if d == nil || d.Price.IncVat <= 0 {
continue
}
lines = append(lines, orderLine{
Reference: d.Provider,
Sku: d.Provider,
Name: "Delivery",
Quantity: 1,
UnitPrice: d.Price.IncVat,
TaxRate: 2500,
})
}
return lines
}
// ---------------------------------------------------------------------------
// Helper: parse session id from path
// ---------------------------------------------------------------------------
func parseSessionID(r *http.Request) (uint64, bool) {
raw := r.PathValue("id")
if raw == "" {
return 0, false
}
cid, ok := cart.ParseCartId(raw)
if !ok {
return 0, false
}
return uint64(cid), true
}
// ---------------------------------------------------------------------------
// Helper: convert CheckoutGrain → UCP CheckoutResponse
// ---------------------------------------------------------------------------
func checkoutGrainToResponse(id string, g *checkout.CheckoutGrain) CheckoutResponse {
resp := CheckoutResponse{
Id: id,
CartId: g.CartId.String(),
Status: "active",
Deliveries: make([]DeliveryResponse, 0, len(g.Deliveries)),
Payments: make([]PaymentResponse, 0, len(g.Payments)),
Totals: Totals{Currency: "SEK"},
}
if g.OrderId != nil && *g.OrderId != "" {
resp.OrderId = g.OrderId
resp.Status = "completed"
}
if g.CartState != nil {
for _, it := range g.CartState.Items {
if it == nil {
continue
}
item := CartItem{
Sku: it.Sku,
Quantity: int(it.Quantity),
UnitPrice: it.Price.IncVat,
TotalPrice: it.TotalPrice.IncVat,
TaxRate: it.Tax,
}
if it.Meta != nil {
item.Name = it.Meta.Name
item.Image = it.Meta.Image
}
resp.Items = append(resp.Items, item)
}
if g.CartState.TotalPrice != nil {
resp.Totals.TotalIncVat = g.CartState.TotalPrice.IncVat
resp.Totals.TotalExVat = g.CartState.TotalPrice.ValueExVat()
resp.Totals.TotalVat = g.CartState.TotalPrice.TotalVat()
}
}
for _, d := range g.Deliveries {
if d == nil {
continue
}
dr := DeliveryResponse{
Id: d.Id,
Provider: d.Provider,
Price: d.Price.IncVat,
Items: d.Items,
}
if d.PickupPoint != nil {
dr.PickupPoint = &PickupPointResp{
Id: d.PickupPoint.Id,
Name: d.PickupPoint.Name,
Address: d.PickupPoint.Address,
City: d.PickupPoint.City,
Zip: d.PickupPoint.Zip,
}
}
resp.Deliveries = append(resp.Deliveries, dr)
}
if g.ContactDetails != nil {
resp.Contact = &ContactResponse{
Email: g.ContactDetails.Email,
Phone: g.ContactDetails.Phone,
Name: g.ContactDetails.Name,
PostalCode: g.ContactDetails.PostalCode,
}
}
for _, p := range g.Payments {
if p == nil {
continue
}
resp.Payments = append(resp.Payments, PaymentResponse{
Id: p.PaymentId,
Provider: p.Provider,
Amount: p.Amount,
Currency: p.Currency,
Status: string(p.Status),
})
}
return resp
}
-153
View File
@@ -1,153 +0,0 @@
package ucp
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
checkoutMessages "git.k6n.net/mats/go-cart-actor/proto/checkout"
"google.golang.org/protobuf/proto"
)
type testCheckoutApplier struct {
grains map[uint64]*checkout.CheckoutGrain
}
func newTestCheckoutApplier() *testCheckoutApplier {
return &testCheckoutApplier{
grains: make(map[uint64]*checkout.CheckoutGrain),
}
}
func (a *testCheckoutApplier) Get(_ context.Context, id uint64) (*checkout.CheckoutGrain, error) {
g, ok := a.grains[id]
if !ok {
g = checkout.NewCheckoutGrain(id, 0, 0, time.UnixMilli(1000000), nil)
a.grains[id] = g
}
return g, nil
}
func (a *testCheckoutApplier) Apply(ctx context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[checkout.CheckoutGrain], error) {
g, err := a.Get(ctx, id)
if err != nil {
return nil, err
}
for _, msg := range msgs {
switch m := msg.(type) {
case *checkoutMessages.InitializeCheckout:
g.CartId = cart.CartId(m.CartId)
}
}
return &actor.MutationResult[checkout.CheckoutGrain]{
Result: *g,
}, nil
}
func TestCheckoutHandler_CreateWithCartId(t *testing.T) {
// Start a mock Cart server.
mockCartServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "/cart/byid/") {
t.Errorf("expected path to contain /cart/byid/, got %q", r.URL.Path)
}
// Return dummy cart state bytes.
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{}`))
}))
defer mockCartServer.Close()
os.Setenv("CART_INTERNAL_URL", mockCartServer.URL)
defer os.Unsetenv("CART_INTERNAL_URL")
applier := newTestCheckoutApplier()
handler := CheckoutHandler(applier)
// Create a dummy cart ID.
dummyCartId, _ := cart.NewCartId()
reqBody := fmt.Sprintf(`{"cartId": %q}`, dummyCartId.String())
req := httptest.NewRequest("POST", "/", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created, got %d. Body: %s", rec.Code, rec.Body.String())
}
var resp CheckoutResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Id == "" {
t.Fatal("expected non-empty checkout session id")
}
if resp.CartId != dummyCartId.String() {
t.Fatalf("expected cartId %q, got %q", dummyCartId.String(), resp.CartId)
}
}
func TestCheckoutHandler_CreateWithLineItemsFallback(t *testing.T) {
// Start a mock Cart server that supports both creating a cart and getting it.
var createdCartId cart.CartId
mockCartServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && strings.Contains(r.URL.Path, "/ucp/v1/carts/") {
var err error
createdCartId, err = cart.NewCartId()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
w.Write([]byte(fmt.Sprintf(`{"id": %q, "status": "active"}`, createdCartId.String())))
return
}
if r.Method == "GET" && strings.Contains(r.URL.Path, "/cart/byid/") {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{}`))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer mockCartServer.Close()
os.Setenv("CART_INTERNAL_URL", mockCartServer.URL)
defer os.Unsetenv("CART_INTERNAL_URL")
applier := newTestCheckoutApplier()
handler := CheckoutHandler(applier)
// POST with line_items and no cartId.
reqBody := `{"line_items": [{"sku": "SKU123", "quantity": 2}]}`
req := httptest.NewRequest("POST", "/", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created, got %d. Body: %s", rec.Code, rec.Body.String())
}
var resp CheckoutResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Id == "" {
t.Fatal("expected non-empty checkout session id")
}
if resp.CartId != createdCartId.String() {
t.Fatalf("expected cartId %q to match created implicit cart %q", resp.CartId, createdCartId.String())
}
}
-421
View File
@@ -1,421 +0,0 @@
package ucp
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/profile"
messages "git.k6n.net/mats/go-cart-actor/proto/profile"
)
// CustomerServer is the UCP REST adapter over the profile grain pool.
type CustomerServer struct {
applier ProfileApplier
}
// NewCustomerServer builds a UCP REST adapter over a profile grain pool.
func NewCustomerServer(applier ProfileApplier) *CustomerServer {
return &CustomerServer{applier: applier}
}
// ---------------------------------------------------------------------------
// UCP Customer endpoints
// ---------------------------------------------------------------------------
// handleCreateCustomer handles POST /customers — generates a new profile ID
// server-side (base62-encoded random 64-bit), applies the profile mutation,
// and returns 201 with the generated ID. Unlike PUT /customers/{id}, the
// caller does not choose the identifier.
func (s *CustomerServer) handleCreateCustomer(w http.ResponseWriter, r *http.Request) {
var req CustomerUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
rawId, err := profile.NewProfileId()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate id")
return
}
id := uint64(rawId)
msg := &messages.SetProfile{
Name: req.Name,
Email: req.Email,
Phone: req.Phone,
Language: req.Language,
Currency: req.Currency,
AvatarUrl: req.AvatarUrl,
}
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create customer: "+err.Error())
return
}
g, err := s.applier.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error())
return
}
writeJSON(w, http.StatusCreated, customerGrainToResponse(rawId.String(), g))
}
// handleGetCustomer handles GET /customers/{id} with ETag-based conditional
// GET support. When the client sends If-None-Match matching the current ETag,
// a 304 Not Modified is returned with no body — the caller reuses their cached
// response. The ETag is derived from the grain's lastChange timestamp and
// customer data hash, so it changes on any mutation.
func (s *CustomerServer) handleGetCustomer(w http.ResponseWriter, r *http.Request) {
id, ok := parseCustomerID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid customer id")
return
}
g, err := s.applier.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "customer not found")
return
}
etag := computeCustomerETag(r.PathValue("id"), g)
if match := r.Header.Get("If-None-Match"); match != "" {
if match == etag || match == "*" {
w.Header().Set("ETag", etag)
w.Header().Set("Vary", "Accept-Encoding")
w.WriteHeader(http.StatusNotModified)
return
}
}
w.Header().Set("ETag", etag)
w.Header().Set("Vary", "Accept-Encoding")
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
}
// handleUpdateCustomer handles PUT /customers/{id}.
func (s *CustomerServer) handleUpdateCustomer(w http.ResponseWriter, r *http.Request) {
id, ok := parseCustomerID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid customer id")
return
}
var req CustomerUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
msg := &messages.SetProfile{
Name: req.Name,
Email: req.Email,
Phone: req.Phone,
Language: req.Language,
Currency: req.Currency,
AvatarUrl: req.AvatarUrl,
}
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update customer: "+err.Error())
return
}
g, err := s.applier.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error())
return
}
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
}
// handleAddAddress handles POST /customers/{id}/addresses.
func (s *CustomerServer) handleAddAddress(w http.ResponseWriter, r *http.Request) {
id, ok := parseCustomerID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid customer id")
return
}
var req AddAddressRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
if req.AddressLine1 == "" || req.City == "" || req.Zip == "" || req.Country == "" {
writeError(w, http.StatusBadRequest, "addressLine1, city, zip, and country are required")
return
}
addr := &messages.Address{
Label: req.Label,
FullName: req.FullName,
AddressLine1: req.AddressLine1,
City: req.City,
State: req.State,
Zip: req.Zip,
Country: req.Country,
IsDefaultShipping: req.IsDefaultShipping,
IsDefaultBilling: req.IsDefaultBilling,
}
if req.AddressLine2 != "" {
addr.AddressLine2 = &req.AddressLine2
}
if req.Phone != "" {
addr.Phone = &req.Phone
}
msg := &messages.AddAddress{Address: addr}
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
writeError(w, http.StatusInternalServerError, "failed to add address: "+err.Error())
return
}
g, err := s.applier.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error())
return
}
writeJSON(w, http.StatusCreated, customerGrainToResponse(r.PathValue("id"), g))
}
// handleUpdateAddress handles PUT /customers/{id}/addresses/{addressId}.
func (s *CustomerServer) handleUpdateAddress(w http.ResponseWriter, r *http.Request) {
id, ok := parseCustomerID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid customer id")
return
}
addressID, ok := parseAddressID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid address id")
return
}
var req UpdateAddressRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
msg := &messages.UpdateAddress{
Id: addressID,
Label: req.Label,
FullName: req.FullName,
AddressLine1: req.AddressLine1,
AddressLine2: req.AddressLine2,
City: req.City,
State: req.State,
Zip: req.Zip,
Country: req.Country,
Phone: req.Phone,
IsDefaultShipping: req.IsDefaultShipping,
IsDefaultBilling: req.IsDefaultBilling,
}
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update address: "+err.Error())
return
}
g, err := s.applier.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error())
return
}
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
}
// handleRemoveAddress handles DELETE /customers/{id}/addresses/{addressId}.
func (s *CustomerServer) handleRemoveAddress(w http.ResponseWriter, r *http.Request) {
id, ok := parseCustomerID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid customer id")
return
}
addressID, ok := parseAddressID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid address id")
return
}
msg := &messages.RemoveAddress{Id: addressID}
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
// Check if the error is a "not found" error.
errStr := err.Error()
if containsNotFound(errStr) {
writeError(w, http.StatusNotFound, fmt.Sprintf("address %d not found", addressID))
return
}
writeError(w, http.StatusInternalServerError, "failed to remove address: "+errStr)
return
}
g, err := s.applier.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error())
return
}
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
}
// ---------------------------------------------------------------------------
// Identity linking helpers
// ---------------------------------------------------------------------------
// linkCartToProfile links a cart to the given profile by applying a LinkCart mutation.
func linkCartToProfile(applier ProfileApplier, ctx context.Context, profileID uint64, cartID uint64) error {
if applier == nil {
return nil
}
_, err := applier.Apply(ctx, profileID, &messages.LinkCart{
CartId: cartID,
Label: "current cart",
})
return err
}
// linkCheckoutToProfile links a checkout to the given profile by applying a LinkCheckout mutation.
func linkCheckoutToProfile(applier ProfileApplier, ctx context.Context, profileID uint64, checkoutID uint64, cartID uint64) error {
if applier == nil {
return nil
}
_, err := applier.Apply(ctx, profileID, &messages.LinkCheckout{
CheckoutId: checkoutID,
CartId: cartID,
})
return err
}
// linkOrderToProfile links an order to the given profile by applying a LinkOrder mutation.
func linkOrderToProfile(applier ProfileApplier, ctx context.Context, profileID uint64, orderReference string, cartID uint64, status string) error {
if applier == nil {
return nil
}
_, err := applier.Apply(ctx, profileID, &messages.LinkOrder{
OrderReference: orderReference,
CartId: cartID,
Status: status,
})
return err
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// parseCustomerID parses the base62 customer id from the path.
func parseCustomerID(r *http.Request) (uint64, bool) {
raw := r.PathValue("id")
if raw == "" {
return 0, false
}
cid, ok := profile.ParseProfileId(raw)
if !ok {
return 0, false
}
return uint64(cid), true
}
// parseAddressID parses the numeric address id from the path.
func parseAddressID(r *http.Request) (uint32, bool) {
raw := r.PathValue("addressId")
if raw == "" {
return 0, false
}
// Address IDs are simple numeric strings.
var id uint32
for _, c := range raw {
if c < '0' || c > '9' {
return 0, false
}
id = id*10 + uint32(c-'0')
}
return id, true
}
// containsNotFound reports whether errStr indicates a "not found" condition.
func containsNotFound(errStr string) bool {
// Check for common not-found patterns in the error message.
for _, pattern := range []string{"not found", "not_found", "notfound"} {
if len(errStr) >= len(pattern) {
for i := 0; i <= len(errStr)-len(pattern); i++ {
match := true
for j := 0; j < len(pattern); j++ {
c1 := errStr[i+j]
c2 := pattern[j]
if c1 >= 'A' && c1 <= 'Z' {
c1 += 32
}
if c1 != c2 {
match = false
break
}
}
if match {
return true
}
}
}
}
return false
}
// computeCustomerETag returns a strong ETag for the customer's current state,
// derived from the grain's lastChange timestamp and a hash of the ID + change
// time. Any mutation (profile update, address change, linking) advances
// lastChange, so the ETag changes when data changes.
func computeCustomerETag(id string, g *profile.ProfileGrain) string {
h := sha256.Sum256([]byte(id + "\x00" + g.GetLastChange().Format(time.RFC3339Nano)))
return `"` + hex.EncodeToString(h[:16]) + `"`
}
// customerGrainToResponse converts a ProfileGrain to a CustomerResponse.
func customerGrainToResponse(id string, g *profile.ProfileGrain) CustomerResponse {
resp := CustomerResponse{
Id: id,
Name: g.Name,
Email: g.Email,
Phone: g.Phone,
Language: g.Language,
Currency: g.Currency,
AvatarUrl: g.AvatarUrl,
Addresses: make([]AddressResponse, 0, len(g.Addresses)),
}
for _, a := range g.Addresses {
resp.Addresses = append(resp.Addresses, AddressResponse{
Id: a.Id,
Label: a.Label,
FullName: a.FullName,
AddressLine1: a.AddressLine1,
AddressLine2: a.AddressLine2,
City: a.City,
State: a.State,
Zip: a.Zip,
Country: a.Country,
Phone: a.Phone,
IsDefaultShipping: a.IsDefaultShipping,
IsDefaultBilling: a.IsDefaultBilling,
})
}
return resp
}
-498
View File
@@ -1,498 +0,0 @@
package ucp
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/profile"
messages "git.k6n.net/mats/go-cart-actor/proto/profile"
"google.golang.org/protobuf/proto"
)
// testProfileApplier implements ProfileApplier with in-memory grains.
type testProfileApplier struct {
grains map[uint64]*profile.ProfileGrain
}
func newTestProfileApplier() *testProfileApplier {
return &testProfileApplier{
grains: make(map[uint64]*profile.ProfileGrain),
}
}
func (a *testProfileApplier) Get(_ context.Context, id uint64) (*profile.ProfileGrain, error) {
g, ok := a.grains[id]
if !ok {
g = profile.NewProfileGrain(id, time.UnixMilli(1000000))
a.grains[id] = g
}
return g, nil
}
func (a *testProfileApplier) Apply(ctx context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error) {
g, err := a.Get(ctx, id)
if err != nil {
return nil, err
}
for _, msg := range msgs {
switch m := msg.(type) {
case *messages.SetProfile:
if m.Name != nil {
g.Name = *m.Name
}
if m.Email != nil {
g.Email = *m.Email
}
if m.Phone != nil {
g.Phone = *m.Phone
}
if m.Language != nil {
g.Language = *m.Language
}
if m.Currency != nil {
g.Currency = *m.Currency
}
if m.AvatarUrl != nil {
g.AvatarUrl = *m.AvatarUrl
}
case *messages.AddAddress:
if m.Address == nil {
continue
}
g.Addresses = append(g.Addresses, profile.StoredAddress{
Id: g.NextAddrId(),
Label: m.Address.Label,
FullName: m.Address.FullName,
AddressLine1: m.Address.AddressLine1,
AddressLine2: m.Address.GetAddressLine2(),
City: m.Address.City,
State: m.Address.State,
Zip: m.Address.Zip,
Country: m.Address.Country,
Phone: m.Address.GetPhone(),
IsDefaultShipping: m.Address.IsDefaultShipping,
IsDefaultBilling: m.Address.IsDefaultBilling,
})
case *messages.UpdateAddress:
for i := range g.Addresses {
if g.Addresses[i].Id == m.Id {
if m.Label != nil {
g.Addresses[i].Label = *m.Label
}
if m.AddressLine1 != nil {
g.Addresses[i].AddressLine1 = *m.AddressLine1
}
}
}
case *messages.RemoveAddress:
for i := range g.Addresses {
if g.Addresses[i].Id == m.Id {
g.Addresses = append(g.Addresses[:i], g.Addresses[i+1:]...)
break
}
}
case *messages.LinkCart:
g.Carts = append(g.Carts, profile.LinkedCart{CartId: m.CartId, Label: m.Label})
case *messages.LinkCheckout:
g.Checkouts = append(g.Checkouts, profile.LinkedCheckout{CheckoutId: m.CheckoutId, CartId: m.CartId})
case *messages.LinkOrder:
g.Orders = append(g.Orders, profile.LinkedOrder{OrderReference: m.OrderReference, CartId: m.CartId, Status: m.Status})
}
}
return &actor.MutationResult[profile.ProfileGrain]{
Result: *g,
}, nil
}
// testCartApplier is a minimal CartApplier mock for combined handler tests.
type testCartApplier struct{}
func (a *testCartApplier) Get(ctx context.Context, id uint64) (*cart.CartGrain, error) {
return nil, fmt.Errorf("not implemented")
}
func (a *testCartApplier) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[cart.CartGrain], error) {
return nil, fmt.Errorf("not implemented")
}
// Helper to get a test profile ID as a base62 string.
func testProfileID(t *testing.T, applier *testProfileApplier) string {
t.Helper()
pid, err := profile.NewProfileId()
if err != nil {
t.Fatalf("failed to generate profile id: %v", err)
}
// Seed the grain.
applier.Get(context.Background(), uint64(pid))
return pid.String()
}
func TestGetCustomer(t *testing.T) {
applier := newTestProfileApplier()
id := testProfileID(t, applier)
handler := CustomerHandler(applier)
// Set up some profile data
pid, _ := profile.ParseProfileId(id)
applier.Apply(context.Background(), uint64(pid), &messages.SetProfile{
Name: proto.String("Alice"),
Email: proto.String("alice@example.com"),
})
// GET /customers/{id}
req := httptest.NewRequest("GET", fmt.Sprintf("/%s", id), nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String())
}
var resp CustomerResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Id != id {
t.Fatalf("expected id %q, got %q", id, resp.Id)
}
if resp.Name != "Alice" {
t.Fatalf("expected Name 'Alice', got %q", resp.Name)
}
if resp.Email != "alice@example.com" {
t.Fatalf("expected Email 'alice@example.com', got %q", resp.Email)
}
}
func TestGetCustomerNotFound(t *testing.T) {
applier := newTestProfileApplier()
handler := CustomerHandler(applier)
// "nonexistent" is actually valid base62, so use an ID with characters outside the alphabet.
req := httptest.NewRequest("GET", "/!invalid!", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 Bad Request for invalid id, got %d. Body: %s", rec.Code, rec.Body.String())
}
}
func TestUpdateCustomer(t *testing.T) {
applier := newTestProfileApplier()
id := testProfileID(t, applier)
handler := CustomerHandler(applier)
body := `{"name": "Bob", "email": "bob@example.com", "phone": "+46701234567", "language": "sv", "currency": "SEK"}`
req := httptest.NewRequest("PUT", fmt.Sprintf("/%s", id), strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String())
}
var resp CustomerResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Name != "Bob" {
t.Fatalf("expected Name 'Bob', got %q", resp.Name)
}
if resp.Email != "bob@example.com" {
t.Fatalf("expected Email 'bob@example.com', got %q", resp.Email)
}
if resp.Phone != "+46701234567" {
t.Fatalf("expected Phone '+46701234567', got %q", resp.Phone)
}
if resp.Language != "sv" {
t.Fatalf("expected Language 'sv', got %q", resp.Language)
}
if resp.Currency != "SEK" {
t.Fatalf("expected Currency 'SEK', got %q", resp.Currency)
}
}
func TestAddAddress(t *testing.T) {
applier := newTestProfileApplier()
id := testProfileID(t, applier)
handler := CustomerHandler(applier)
body := `{
"label": "Home",
"fullName": "Alice",
"addressLine1": "Storgatan 1",
"addressLine2": "Lgh 101",
"city": "Stockholm",
"zip": "111 22",
"country": "SE",
"phone": "+46701234567"
}`
req := httptest.NewRequest("POST", fmt.Sprintf("/%s/addresses", id), strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created, got %d. Body: %s", rec.Code, rec.Body.String())
}
var resp CustomerResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if len(resp.Addresses) != 1 {
t.Fatalf("expected 1 address, got %d", len(resp.Addresses))
}
if resp.Addresses[0].Label != "Home" {
t.Fatalf("expected address label 'Home', got %q", resp.Addresses[0].Label)
}
if resp.Addresses[0].AddressLine1 != "Storgatan 1" {
t.Fatalf("expected addressLine1 'Storgatan 1', got %q", resp.Addresses[0].AddressLine1)
}
if resp.Addresses[0].AddressLine2 != "Lgh 101" {
t.Fatalf("expected addressLine2 'Lgh 101', got %q", resp.Addresses[0].AddressLine2)
}
}
func TestAddAddressMissingRequired(t *testing.T) {
applier := newTestProfileApplier()
id := testProfileID(t, applier)
handler := CustomerHandler(applier)
// Missing addressLine1
body := `{"city": "Stockholm", "zip": "111 22", "country": "SE"}`
req := httptest.NewRequest("POST", fmt.Sprintf("/%s/addresses", id), strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 Bad Request for missing required fields, got %d. Body: %s", rec.Code, rec.Body.String())
}
}
func TestUpdateAddress(t *testing.T) {
applier := newTestProfileApplier()
id := testProfileID(t, applier)
handler := CustomerHandler(applier)
// First add an address
pid, _ := profile.ParseProfileId(id)
applier.Apply(context.Background(), uint64(pid), &messages.AddAddress{
Address: &messages.Address{
Label: "Old Home",
FullName: "Alice",
AddressLine1: "Old Street 1",
City: "Old City",
Zip: "000 00",
Country: "SE",
},
})
// Get the address id from the grain
grain, _ := applier.Get(context.Background(), uint64(pid))
if len(grain.Addresses) == 0 {
t.Fatal("expected at least one address")
}
addrID := grain.Addresses[0].Id
body := fmt.Sprintf(`{"label": "New Home", "addressLine1": "New Street 2"}`)
req := httptest.NewRequest("PUT", fmt.Sprintf("/%s/addresses/%d", id, addrID), strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String())
}
var resp CustomerResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if len(resp.Addresses) != 1 {
t.Fatalf("expected 1 address, got %d", len(resp.Addresses))
}
if resp.Addresses[0].Label != "New Home" {
t.Fatalf("expected label 'New Home', got %q", resp.Addresses[0].Label)
}
if resp.Addresses[0].AddressLine1 != "New Street 2" {
t.Fatalf("expected addressLine1 'New Street 2', got %q", resp.Addresses[0].AddressLine1)
}
// Unchanged fields should still be there
if resp.Addresses[0].City != "Old City" {
t.Fatalf("expected City 'Old City', got %q", resp.Addresses[0].City)
}
}
func TestUpdateAddressInvalidID(t *testing.T) {
applier := newTestProfileApplier()
id := testProfileID(t, applier)
handler := CustomerHandler(applier)
body := `{"label": "New Home"}`
req := httptest.NewRequest("PUT", fmt.Sprintf("/%s/addresses/abc", id), strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 Bad Request for invalid address id, got %d", rec.Code)
}
}
func TestRemoveAddress(t *testing.T) {
applier := newTestProfileApplier()
id := testProfileID(t, applier)
handler := CustomerHandler(applier)
// First add an address
pid, _ := profile.ParseProfileId(id)
applier.Apply(context.Background(), uint64(pid), &messages.AddAddress{
Address: &messages.Address{
Label: "Remove Me",
FullName: "Alice",
AddressLine1: "Storgatan 1",
City: "Stockholm",
Zip: "111 22",
Country: "SE",
},
})
grain, _ := applier.Get(context.Background(), uint64(pid))
if len(grain.Addresses) == 0 {
t.Fatal("expected at least one address")
}
addrID := grain.Addresses[0].Id
// DELETE /customers/{id}/addresses/{addressId}
req := httptest.NewRequest("DELETE", fmt.Sprintf("/%s/addresses/%d", id, addrID), nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String())
}
grain, _ = applier.Get(context.Background(), uint64(pid))
if len(grain.Addresses) != 0 {
t.Fatalf("expected 0 addresses after removal, got %d", len(grain.Addresses))
}
}
func TestRemoveAddressNotFound(t *testing.T) {
applier := newTestProfileApplier()
pid, err := profile.NewProfileId()
if err != nil {
t.Fatalf("failed to generate profile id: %v", err)
}
applier.Get(context.Background(), uint64(pid))
id := pid.String()
handler := CustomerHandler(applier)
req := httptest.NewRequest("DELETE", fmt.Sprintf("/%s/addresses/999", id), nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
// Test applier silently ignores not-found removals (returns no error).
// In production, HandleRemoveAddress returns an error which maps to 404.
// Accept 200 here since the test applier is a simplified mock.
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 (test applier limitation), got %d. Body: %s", rec.Code, rec.Body.String())
}
}
// TestRemoveAddressNotFoundRealistic tests the real mutation handler path via the registry.
// It uses the real HandleRemoveAddress mutation to verify 404 behavior.
func TestRemoveAddressNotFoundMutation(t *testing.T) {
// Test the real HandleRemoveAddress mutation directly.
g := profile.NewProfileGrain(1, time.Now())
// Add an address via the real handler
if err := profile.HandleAddAddress(g, &messages.AddAddress{
Address: &messages.Address{
Label: "Test",
FullName: "Alice",
AddressLine1: "Storgatan 1",
City: "Stockholm",
Zip: "111 22",
Country: "SE",
},
}); err != nil {
t.Fatalf("HandleAddAddress failed: %v", err)
}
// Try to remove address 999 (doesn't exist)
err := profile.HandleRemoveAddress(g, &messages.RemoveAddress{Id: 999})
if err == nil {
t.Fatal("expected error for non-existent address, got nil")
}
expected := "RemoveAddress: address 999 not found"
if err.Error() != expected {
t.Fatalf("expected error %q, got %q", expected, err.Error())
}
}
func TestCustomerHandlerInvalidID(t *testing.T) {
applier := newTestProfileApplier()
handler := CustomerHandler(applier)
// Path "/" now has POST / registered (create customer), so GET / is 405.
// Paths with invalid base62 characters should get 400.
tests := []struct {
path string
expectedStatus int
}{
{"/", http.StatusMethodNotAllowed}, // POST / exists, GET doesn't
{"/!invalid!", http.StatusBadRequest}, // has non-base62 chars
}
for _, tt := range tests {
req := httptest.NewRequest("GET", tt.path, nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != tt.expectedStatus {
t.Fatalf("for path %q: expected %d, got %d. Body: %s", tt.path, tt.expectedStatus, rec.Code, rec.Body.String())
}
}
}
func TestCustomerHandlerThroughCombinedMount(t *testing.T) {
// Test that the combined Handler correctly mounts customer endpoints.
// Use a mock CartApplier (bare minimum — never called in this test).
mockCart := &testCartApplier{}
profileApplier := newTestProfileApplier()
pid, _ := profile.NewProfileId()
profileApplier.Get(context.Background(), uint64(pid))
handler := Handler(mockCart, Options{
ProfileApplier: profileApplier,
})
// GET /customers/{id} should work
id := pid.String()
req := httptest.NewRequest("GET", fmt.Sprintf("/customers/%s", id), nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String())
}
var resp CustomerResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Id != id {
t.Fatalf("expected id %q, got %q", id, resp.Id)
}
}
-174
View File
@@ -1,174 +0,0 @@
package ucp
import "net/http"
// ---------------------------------------------------------------------------
// Order HTTP handler mounting
// ---------------------------------------------------------------------------
// OrderHandler returns an http.Handler that routes UCP order REST endpoints.
// Mount it under any prefix (e.g. /ucp/v1) in a ServeMux:
//
// mux.Handle("/ucp/v1/orders", ucp.OrderHandler(pool))
// mux.Handle("/ucp/v1/orders/", ucp.OrderHandler(pool))
//
// To sign responses with RFC 9421 HTTP Message Signatures, wrap with WithSigning:
//
// mux.Handle("/ucp/v1/orders", ucp.WithSigning(ucp.OrderHandler(pool), signer))
func OrderHandler(applier OrderReadApplier) http.Handler {
s := NewOrderServer(applier)
mux := http.NewServeMux()
mux.HandleFunc("GET /{id}", s.handleGetOrder)
mux.HandleFunc("POST /{id}/cancel", s.handleCancelOrder)
mux.HandleFunc("POST /{id}/fulfillments", s.handleCreateFulfillment)
mux.HandleFunc("POST /{id}/returns", s.handleRequestReturn)
mux.HandleFunc("POST /{id}/refunds", s.handleIssueRefund)
return withUCPAgent(withJSONContentType(mux))
}
// ---------------------------------------------------------------------------
// Cart HTTP handler mounting
// ---------------------------------------------------------------------------
// CartHandler returns an http.Handler that routes UCP cart REST endpoints.
// Mount it under any prefix (e.g. /ucp/v1) in a ServeMux:
//
// mux.Handle("/ucp/v1/carts", ucp.CartHandler(pool))
// mux.Handle("/ucp/v1/carts/", ucp.CartHandler(pool))
//
// To sign responses with RFC 9421 HTTP Message Signatures, wrap with WithSigning:
//
// mux.Handle("/ucp/v1/carts", ucp.WithSigning(ucp.CartHandler(pool), signer))
func CartHandler(applier CartApplier) http.Handler {
s := NewCartServer(applier)
mux := http.NewServeMux()
mux.HandleFunc("POST /", s.handleCreateCart)
mux.HandleFunc("GET /{id}", s.handleGetCart)
mux.HandleFunc("PUT /{id}", s.handleUpdateCart)
mux.HandleFunc("POST /{id}/cancel", s.handleCancelCart)
return withUCPAgent(withJSONContentType(mux))
}
// ---------------------------------------------------------------------------
// Checkout HTTP handler mounting
// ---------------------------------------------------------------------------
// CheckoutHandler returns an http.Handler that routes UCP checkout endpoints.
// An optional OrderApplier can be provided for real order creation on complete.
//
// mux.Handle("/ucp/v1/checkout-sessions", ucp.CheckoutHandler(pool))
// mux.Handle("/ucp/v1/checkout-sessions/", ucp.CheckoutHandler(pool, orderSvc))
//
// To sign responses with RFC 9421 HTTP Message Signatures, wrap with WithSigning:
//
// mux.Handle("/ucp/v1/checkout-sessions", ucp.WithSigning(ucp.CheckoutHandler(pool), signer))
func CheckoutHandler(applier CheckoutApplier, orderSvc ...OrderApplier) http.Handler {
s := NewCheckoutServer(applier, orderSvc...)
mux := http.NewServeMux()
mux.HandleFunc("POST /", s.handleCreateCheckout)
mux.HandleFunc("GET /{id}", s.handleGetCheckout)
mux.HandleFunc("PUT /{id}", s.handleUpdateCheckout)
mux.HandleFunc("POST /{id}/complete", s.handleCompleteCheckout)
mux.HandleFunc("POST /{id}/cancel", s.handleCancelCheckout)
return withUCPAgent(withJSONContentType(mux))
}
// ---------------------------------------------------------------------------
// Customer HTTP handler mounting
// ---------------------------------------------------------------------------
// CustomerHandler returns an http.Handler that routes UCP customer/profile
// REST endpoints. Mount it under any prefix (e.g. /ucp/v1) in a ServeMux:
//
// mux.Handle("/ucp/v1/customers", ucp.CustomerHandler(pool))
// mux.Handle("/ucp/v1/customers/", ucp.CustomerHandler(pool))
//
// To sign responses with RFC 9421 HTTP Message Signatures, wrap with WithSigning:
//
// mux.Handle("/ucp/v1/customers", ucp.WithSigning(ucp.CustomerHandler(pool), signer))
func CustomerHandler(applier ProfileApplier) http.Handler {
s := NewCustomerServer(applier)
mux := http.NewServeMux()
mux.HandleFunc("POST /", s.handleCreateCustomer)
mux.HandleFunc("GET /{id}", s.handleGetCustomer)
mux.HandleFunc("PUT /{id}", s.handleUpdateCustomer)
mux.HandleFunc("POST /{id}/addresses", s.handleAddAddress)
mux.HandleFunc("PUT /{id}/addresses/{addressId}", s.handleUpdateAddress)
mux.HandleFunc("DELETE /{id}/addresses/{addressId}", s.handleRemoveAddress)
return withUCPAgent(withJSONContentType(mux))
}
// ---------------------------------------------------------------------------
// Combined handler for mounting all UCP endpoints under one prefix
// ---------------------------------------------------------------------------
// Options controls optional features of the combined handler.
type Options struct {
CheckoutApplier CheckoutApplier // when set, mounts /checkout-sessions
OrderApplier OrderApplier // optional order creation for complete endpoint
ProfileApplier ProfileApplier // when set, mounts /customers + enables identity linking
}
// Handler returns an http.Handler that serves all mounted UCP endpoints under
// a single prefix. Usage:
//
// mux.Handle("/ucp/v1/", ucp.Handler(pool, ucp.Options{
// CheckoutApplier: checkoutPool,
// ProfileApplier: profilePool,
// }))
func Handler(cartApplier CartApplier, opts Options) http.Handler {
mux := http.NewServeMux()
// Cart endpoints
cartSrv := NewCartServer(cartApplier)
if opts.ProfileApplier != nil {
cartSrv.SetProfileApplier(opts.ProfileApplier)
}
mux.HandleFunc("POST /carts", cartSrv.handleCreateCart)
mux.HandleFunc("GET /carts/{id}", cartSrv.handleGetCart)
mux.HandleFunc("PUT /carts/{id}", cartSrv.handleUpdateCart)
mux.HandleFunc("POST /carts/{id}/cancel", cartSrv.handleCancelCart)
// Checkout endpoints (optional)
if opts.CheckoutApplier != nil {
var orderOpts []OrderApplier
if opts.OrderApplier != nil {
orderOpts = []OrderApplier{opts.OrderApplier}
}
checkoutSrv := NewCheckoutServer(opts.CheckoutApplier, orderOpts...)
if opts.ProfileApplier != nil {
checkoutSrv.SetProfileApplier(opts.ProfileApplier)
}
mux.HandleFunc("POST /checkout-sessions", checkoutSrv.handleCreateCheckout)
mux.HandleFunc("GET /checkout-sessions/{id}", checkoutSrv.handleGetCheckout)
mux.HandleFunc("PUT /checkout-sessions/{id}", checkoutSrv.handleUpdateCheckout)
mux.HandleFunc("POST /checkout-sessions/{id}/complete", checkoutSrv.handleCompleteCheckout)
mux.HandleFunc("POST /checkout-sessions/{id}/cancel", checkoutSrv.handleCancelCheckout)
}
// Customer endpoints (optional)
if opts.ProfileApplier != nil {
customerSrv := NewCustomerServer(opts.ProfileApplier)
mux.HandleFunc("POST /customers", customerSrv.handleCreateCustomer)
mux.HandleFunc("GET /customers/{id}", customerSrv.handleGetCustomer)
mux.HandleFunc("PUT /customers/{id}", customerSrv.handleUpdateCustomer)
mux.HandleFunc("POST /customers/{id}/addresses", customerSrv.handleAddAddress)
mux.HandleFunc("PUT /customers/{id}/addresses/{addressId}", customerSrv.handleUpdateAddress)
mux.HandleFunc("DELETE /customers/{id}/addresses/{addressId}", customerSrv.handleRemoveAddress)
}
return withUCPAgent(withJSONContentType(mux))
}
// withUCPAgent wraps a handler with UCP-Agent header parsing middleware.
func withUCPAgent(next http.Handler) http.Handler {
return WithUCPAgent(next)
}
// withJSONContentType is a middleware that ensures all responses have Content-Type: application/json.
func withJSONContentType(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
next.ServeHTTP(w, r)
})
}
-329
View File
@@ -1,329 +0,0 @@
package ucp
import (
"encoding/json"
"net/http"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/order"
orderMessages "git.k6n.net/mats/go-cart-actor/proto/order"
)
// OrderServer is the UCP REST adapter over the order grain pool.
type OrderServer struct {
applier OrderReadApplier
}
// NewOrderServer builds a UCP REST adapter over an order grain pool.
func NewOrderServer(applier OrderReadApplier) *OrderServer {
return &OrderServer{applier: applier}
}
// ---------------------------------------------------------------------------
// UCP Order endpoints
// ---------------------------------------------------------------------------
// handleGetOrder handles GET /orders/{id}.
func (s *OrderServer) handleGetOrder(w http.ResponseWriter, r *http.Request) {
g, err := s.loadOrder(w, r)
if err != nil || g == nil {
return
}
writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), g))
}
// handleCancelOrder handles POST /orders/{id}/cancel.
func (s *OrderServer) handleCancelOrder(w http.ResponseWriter, r *http.Request) {
g, err := s.loadOrder(w, r)
if err != nil || g == nil {
return
}
var req struct {
Reason string `json:"reason,omitempty"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
res, applyErr := s.applier.Apply(r.Context(), g.Id, &orderMessages.CancelOrder{
Reason: req.Reason,
AtMs: time.Now().UnixMilli(),
})
if applyErr != nil || res == nil || mutationRejected(res) {
msg := "failed to cancel order"
if res != nil && len(res.Mutations) > 0 && res.Mutations[0].Error != nil {
msg += ": " + res.Mutations[0].Error.Error()
} else if applyErr != nil {
msg += ": " + applyErr.Error()
}
writeError(w, http.StatusUnprocessableEntity, msg)
return
}
updated, _ := s.applier.Get(r.Context(), g.Id)
if updated != nil {
writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), updated))
} else {
writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), g))
}
}
// handleCreateFulfillment handles POST /orders/{id}/fulfillments.
func (s *OrderServer) handleCreateFulfillment(w http.ResponseWriter, r *http.Request) {
g, err := s.loadOrder(w, r)
if err != nil || g == nil {
return
}
var req struct {
Carrier string `json:"carrier,omitempty"`
TrackingNumber string `json:"trackingNumber,omitempty"`
TrackingURI string `json:"trackingUri,omitempty"`
Lines []struct {
Reference string `json:"reference"`
Quantity int32 `json:"quantity"`
} `json:"lines,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
fid, _ := order.NewOrderId()
msg := &orderMessages.CreateFulfillment{
Id: "f_" + fid.String(),
Carrier: req.Carrier,
TrackingNumber: req.TrackingNumber,
TrackingUri: req.TrackingURI,
AtMs: time.Now().UnixMilli(),
}
for _, l := range req.Lines {
msg.Lines = append(msg.Lines, &orderMessages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity})
}
res, applyErr := s.applier.Apply(r.Context(), g.Id, msg)
if applyErr != nil || res == nil || mutationRejected(res) {
msg := "failed to create fulfillment"
if res != nil && len(res.Mutations) > 0 && res.Mutations[0].Error != nil {
msg += ": " + res.Mutations[0].Error.Error()
} else if applyErr != nil {
msg += ": " + applyErr.Error()
}
writeError(w, http.StatusUnprocessableEntity, msg)
return
}
updated, _ := s.applier.Get(r.Context(), g.Id)
writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), updated))
}
// handleRequestReturn handles POST /orders/{id}/returns.
func (s *OrderServer) handleRequestReturn(w http.ResponseWriter, r *http.Request) {
g, err := s.loadOrder(w, r)
if err != nil || g == nil {
return
}
var req struct {
Reason string `json:"reason,omitempty"`
Lines []struct {
Reference string `json:"reference"`
Quantity int32 `json:"quantity"`
} `json:"lines,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
rid, _ := order.NewOrderId()
msg := &orderMessages.RequestReturn{
Id: "r_" + rid.String(),
Reason: req.Reason,
AtMs: time.Now().UnixMilli(),
}
for _, l := range req.Lines {
msg.Lines = append(msg.Lines, &orderMessages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity})
}
res, applyErr := s.applier.Apply(r.Context(), g.Id, msg)
if applyErr != nil || res == nil || mutationRejected(res) {
msg := "failed to request return"
if res != nil && len(res.Mutations) > 0 && res.Mutations[0].Error != nil {
msg += ": " + res.Mutations[0].Error.Error()
} else if applyErr != nil {
msg += ": " + applyErr.Error()
}
writeError(w, http.StatusUnprocessableEntity, msg)
return
}
updated, _ := s.applier.Get(r.Context(), g.Id)
writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), updated))
}
// handleIssueRefund handles POST /orders/{id}/refunds.
func (s *OrderServer) handleIssueRefund(w http.ResponseWriter, r *http.Request) {
g, err := s.loadOrder(w, r)
if err != nil || g == nil {
return
}
var req struct {
Amount int64 `json:"amount,omitempty"` // minor units; omit for full remaining
}
_ = json.NewDecoder(r.Body).Decode(&req)
amount := req.Amount
if amount <= 0 {
amount = g.CapturedAmount - g.RefundedAmount
}
if amount <= 0 {
writeError(w, http.StatusBadRequest, "no captured amount to refund")
return
}
idStr := order.OrderId(g.Id).String()
res, applyErr := s.applier.Apply(r.Context(), g.Id, &orderMessages.IssueRefund{
Provider: "ucp",
Amount: amount,
Reference: "ref-ucp-" + idStr,
AtMs: time.Now().UnixMilli(),
})
if applyErr != nil || res == nil || mutationRejected(res) {
msg := "failed to issue refund"
if res != nil && len(res.Mutations) > 0 && res.Mutations[0].Error != nil {
msg += ": " + res.Mutations[0].Error.Error()
} else if applyErr != nil {
msg += ": " + applyErr.Error()
}
writeError(w, http.StatusUnprocessableEntity, msg)
return
}
updated, _ := s.applier.Get(r.Context(), g.Id)
writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), updated))
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// mutationRejected returns true if the Apply result indicates the mutation was
// rejected (business-rule violation rather than infrastructure error).
func mutationRejected(res *actor.MutationResult[order.OrderGrain]) bool {
return len(res.Mutations) > 0 && res.Mutations[0].Error != nil
}
// loadOrder parses the id from the path and returns the grain, or writes an
// error response and returns nil.
func (s *OrderServer) loadOrder(w http.ResponseWriter, r *http.Request) (*order.OrderGrain, error) {
raw := r.PathValue("id")
if raw == "" {
writeError(w, http.StatusBadRequest, "missing order id")
return nil, nil
}
id, ok := order.ParseOrderId(raw)
if !ok {
writeError(w, http.StatusBadRequest, "invalid order id")
return nil, nil
}
g, err := s.applier.Get(r.Context(), uint64(id))
if err != nil {
writeError(w, http.StatusNotFound, "order not found")
return nil, nil
}
if g.Status == order.StatusNew {
writeError(w, http.StatusNotFound, "order not found")
return nil, nil
}
return g, nil
}
// orderGrainToResponse converts an OrderGrain to an OrderResponse.
func orderGrainToResponse(id string, g *order.OrderGrain) OrderResponse {
resp := OrderResponse{
OrderId: id,
Reference: g.OrderReference,
Status: string(g.Status),
Currency: g.Currency,
TotalAmount: g.TotalAmount,
TotalTax: g.TotalTax,
Lines: make([]OrderLineResp, 0, len(g.Lines)),
Payments: make([]OrderPaymentResp, 0, len(g.Payments)),
Fulfillments: make([]OrderFulfillmentResp, 0, len(g.Fulfillments)),
Returns: make([]OrderReturnResp, 0, len(g.Returns)),
Refunds: make([]OrderRefundResp, 0, len(g.Refunds)),
}
for _, l := range g.Lines {
resp.Lines = append(resp.Lines, OrderLineResp{
Reference: l.Reference,
Sku: l.Sku,
Name: l.Name,
Quantity: l.Quantity,
UnitPrice: l.UnitPrice,
TaxRate: l.TaxRate,
TotalAmount: l.TotalAmount,
TotalTax: l.TotalTax,
Fulfilled: l.Fulfilled,
})
}
for _, p := range g.Payments {
if p == nil {
continue
}
resp.Payments = append(resp.Payments, OrderPaymentResp{
Provider: p.Provider,
Authorized: p.Authorized,
Captured: p.Captured,
Refunded: p.Refunded,
AuthRef: p.AuthRef,
CaptureRef: p.CaptureRef,
})
}
for _, f := range g.Fulfillments {
lines := make([]OrderLineEntry, 0, len(f.Lines))
for _, l := range f.Lines {
lines = append(lines, OrderLineEntry{Reference: l.Reference, Quantity: l.Quantity})
}
resp.Fulfillments = append(resp.Fulfillments, OrderFulfillmentResp{
Id: f.ID,
Carrier: f.Carrier,
TrackingNumber: f.TrackingNumber,
TrackingURI: f.TrackingURI,
Lines: lines,
})
}
for _, r := range g.Returns {
lines := make([]OrderLineEntry, 0, len(r.Lines))
for _, l := range r.Lines {
lines = append(lines, OrderLineEntry{Reference: l.Reference, Quantity: l.Quantity})
}
resp.Returns = append(resp.Returns, OrderReturnResp{
Id: r.ID,
Reason: r.Reason,
Lines: lines,
})
}
for _, r := range g.Refunds {
resp.Refunds = append(resp.Refunds, OrderRefundResp{
Provider: r.Provider,
Amount: r.Amount,
Reference: r.Reference,
})
}
resp.CapturedAmount = g.CapturedAmount
resp.RefundedAmount = g.RefundedAmount
resp.CustomerEmail = g.CustomerEmail
resp.CustomerName = g.CustomerName
resp.CartId = g.CartId
resp.PlacedAt = g.PlacedAt
resp.UpdatedAt = g.UpdatedAt
return resp
}
-300
View File
@@ -1,300 +0,0 @@
package ucp
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/order"
messages "git.k6n.net/mats/go-cart-actor/proto/order"
"google.golang.org/protobuf/proto"
)
// testOrderApplier implements OrderReadApplier with an in-memory map of grains.
type testOrderApplier struct {
grains map[uint64]*order.OrderGrain
}
func newTestOrderApplier() *testOrderApplier {
return &testOrderApplier{
grains: make(map[uint64]*order.OrderGrain),
}
}
func (a *testOrderApplier) Get(_ context.Context, id uint64) (*order.OrderGrain, error) {
g, ok := a.grains[id]
if !ok {
// Auto-create so non-existent orders return StatusNew (expected 404).
g = order.NewOrderGrain(id, time.UnixMilli(1000000))
a.grains[id] = g
}
return g, nil
}
func (a *testOrderApplier) Apply(_ context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[order.OrderGrain], error) {
g, err := a.Get(nil, id)
if err != nil {
return nil, err
}
results := make([]actor.ApplyResult, len(msgs))
for i, msg := range msgs {
results[i] = actor.ApplyResult{Type: string(msg.ProtoReflect().Descriptor().FullName())}
switch m := msg.(type) {
case *messages.CancelOrder:
if g.Status != order.StatusPending && g.Status != order.StatusAuthorized {
results[i].Error = errRejected("cannot cancel in status " + string(g.Status))
break
}
g.Status = order.StatusCancelled
g.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
case *messages.CreateFulfillment:
if g.Status != order.StatusCaptured && g.Status != order.StatusPartiallyFulfilled {
results[i].Error = errRejected("cannot fulfill in status " + string(g.Status))
break
}
fulfillment := order.Fulfillment{
ID: m.Id,
Carrier: m.Carrier,
TrackingNumber: m.TrackingNumber,
TrackingURI: m.TrackingUri,
}
for _, fl := range m.Lines {
fulfillment.Lines = append(fulfillment.Lines, order.FulfillmentEntry{
Reference: fl.Reference,
Quantity: int(fl.Quantity),
})
}
g.Fulfillments = append(g.Fulfillments, fulfillment)
g.Status = order.StatusFulfilled
case *messages.RequestReturn:
ret := order.Return{
ID: m.Id,
Reason: m.Reason,
}
for _, fl := range m.Lines {
ret.Lines = append(ret.Lines, order.FulfillmentEntry{
Reference: fl.Reference,
Quantity: int(fl.Quantity),
})
}
g.Returns = append(g.Returns, ret)
case *messages.IssueRefund:
g.Refunds = append(g.Refunds, order.Refund{
Provider: m.Provider,
Amount: m.Amount,
Reference: m.Reference,
})
g.RefundedAmount += m.Amount
if g.RefundedAmount >= g.CapturedAmount {
g.Status = order.StatusRefunded
}
}
}
return &actor.MutationResult[order.OrderGrain]{
Result: *g,
Mutations: results,
}, nil
}
func errRejected(msg string) *actor.MutationError {
return &actor.MutationError{Message: msg}
}
// seedOrder places an order in the test applier with a given status.
func seedOrder(a *testOrderApplier, status order.Status) string {
id, _ := order.NewOrderId()
g, _ := a.Get(nil, uint64(id))
g.Status = status
g.Currency = "SEK"
g.TotalAmount = 10000
g.TotalTax = 2000
g.Lines = []order.Line{
{Reference: "test-1", Sku: "test-1", Name: "Test Item", Quantity: 2, UnitPrice: 5000, TaxRate: 2500, TotalAmount: 10000, TotalTax: 2000},
}
if status == order.StatusCaptured {
g.CapturedAmount = 10000
}
return id.String()
}
func TestOrderHandler_GetOrder(t *testing.T) {
a := newTestOrderApplier()
h := OrderHandler(a)
oid := seedOrder(a, order.StatusCaptured)
req := httptest.NewRequest("GET", "/"+oid, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var resp OrderResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.OrderId != oid {
t.Fatalf("expected orderId %q, got %q", oid, resp.OrderId)
}
if resp.Status != "captured" {
t.Fatalf("expected status 'captured', got %q", resp.Status)
}
if len(resp.Lines) != 1 {
t.Fatalf("expected 1 line, got %d", len(resp.Lines))
}
if resp.Lines[0].Sku != "test-1" {
t.Fatalf("expected sku 'test-1', got %q", resp.Lines[0].Sku)
}
}
func TestOrderHandler_GetOrderNotFound(t *testing.T) {
a := newTestOrderApplier()
h := OrderHandler(a)
// Non-existent order (not seeded) returns 404.
req := httptest.NewRequest("GET", "/nonexistent", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404 for non-existent order (StatusNew -> 404), got %d", rec.Code)
}
}
func TestOrderHandler_CancelOrder(t *testing.T) {
a := newTestOrderApplier()
h := OrderHandler(a)
oid := seedOrder(a, order.StatusAuthorized)
body := `{"reason": "customer request"}`
req := httptest.NewRequest("POST", "/"+oid+"/cancel", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var resp OrderResponse
json.NewDecoder(rec.Body).Decode(&resp)
if resp.Status != "cancelled" {
t.Fatalf("expected status 'cancelled', got %q", resp.Status)
}
}
func TestOrderHandler_CancelOrder_IllegalState(t *testing.T) {
a := newTestOrderApplier()
h := OrderHandler(a)
oid := seedOrder(a, order.StatusFulfilled) // can't cancel from fulfilled
req := httptest.NewRequest("POST", "/"+oid+"/cancel", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422 for illegal transition, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestOrderHandler_CreateFulfillment(t *testing.T) {
a := newTestOrderApplier()
h := OrderHandler(a)
oid := seedOrder(a, order.StatusCaptured)
body := `{"carrier":"PostNord","trackingNumber":"ABC123","lines":[{"reference":"test-1","quantity":2}]}`
req := httptest.NewRequest("POST", "/"+oid+"/fulfillments", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var resp OrderResponse
json.NewDecoder(rec.Body).Decode(&resp)
if len(resp.Fulfillments) != 1 {
t.Fatalf("expected 1 fulfillment, got %d", len(resp.Fulfillments))
}
if resp.Fulfillments[0].Carrier != "PostNord" {
t.Fatalf("expected carrier 'PostNord', got %q", resp.Fulfillments[0].Carrier)
}
if resp.Fulfillments[0].TrackingNumber != "ABC123" {
t.Fatalf("expected tracking 'ABC123', got %q", resp.Fulfillments[0].TrackingNumber)
}
}
func TestOrderHandler_RequestReturn(t *testing.T) {
a := newTestOrderApplier()
h := OrderHandler(a)
oid := seedOrder(a, order.StatusFulfilled)
body := `{"reason":"defective","lines":[{"reference":"test-1","quantity":1}]}`
req := httptest.NewRequest("POST", "/"+oid+"/returns", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var resp OrderResponse
json.NewDecoder(rec.Body).Decode(&resp)
if len(resp.Returns) != 1 {
t.Fatalf("expected 1 return, got %d", len(resp.Returns))
}
if resp.Returns[0].Reason != "defective" {
t.Fatalf("expected reason 'defective', got %q", resp.Returns[0].Reason)
}
}
func TestOrderHandler_IssueRefund(t *testing.T) {
a := newTestOrderApplier()
h := OrderHandler(a)
oid := seedOrder(a, order.StatusCaptured)
body := `{"amount":3000}`
req := httptest.NewRequest("POST", "/"+oid+"/refunds", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var resp OrderResponse
json.NewDecoder(rec.Body).Decode(&resp)
if len(resp.Refunds) != 1 {
t.Fatalf("expected 1 refund, got %d", len(resp.Refunds))
}
if resp.Refunds[0].Amount != 3000 {
t.Fatalf("expected amount 3000, got %d", resp.Refunds[0].Amount)
}
}
func TestOrderHandler_SignatureIntegration(t *testing.T) {
cfg := mustLoadTestKey(t)
a := newTestOrderApplier()
oid := seedOrder(a, order.StatusCaptured)
h := WithSigning(OrderHandler(a), cfg)
req := httptest.NewRequest("GET", "/"+oid, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
if rec.Header().Get("Signature-Input") == "" {
t.Fatal("expected Signature-Input with signing enabled")
}
}
-183
View File
@@ -1,183 +0,0 @@
package ucp
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
// ---------------------------------------------------------------------------
// SigningConfig — ECDSA P-256 key for RFC 9421 HTTP Message Signatures
// ---------------------------------------------------------------------------
// SigningConfig holds the ECDSA P-256 private key and key ID for signing UCP
// HTTP responses with RFC 9421 HTTP Message Signatures. The corresponding
// public key is published in the UCP profile (signing_keys) and served at
// /.well-known/ucp so any UCP platform can verify response authenticity.
type SigningConfig struct {
PrivateKey *ecdsa.PrivateKey
KeyID string // matches the kid in the UCP profile signing_keys
}
// NewSigningConfigFromPEM parses a PEM-encoded ECDSA P-256 private key and
// returns a SigningConfig ready for use.
//
// data — PEM block containing an EC PRIVATE KEY (PKCS#8 or SEC1)
// keyID — the kid matched by the UCP profile signing_keys entry
func NewSigningConfigFromPEM(pemData []byte, keyID string) (*SigningConfig, error) {
block, _ := pem.Decode(pemData)
if block == nil {
return nil, fmt.Errorf("ucp signing: no PEM block found")
}
var priv any
var err error
switch block.Type {
case "EC PRIVATE KEY":
// SEC1 format (openssl ecparam -genkey -name prime256v1)
priv, err = x509.ParseECPrivateKey(block.Bytes)
case "PRIVATE KEY":
// PKCS#8 format
priv, err = x509.ParsePKCS8PrivateKey(block.Bytes)
default:
return nil, fmt.Errorf("ucp signing: unsupported PEM type %q", block.Type)
}
if err != nil {
return nil, fmt.Errorf("ucp signing: parse key: %w", err)
}
ecKey, ok := priv.(*ecdsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("ucp signing: key is not ECDSA")
}
if ecKey.Curve != elliptic.P256() {
return nil, fmt.Errorf("ucp signing: key is not P-256 (got %s)", ecKey.Curve.Params().Name)
}
return &SigningConfig{PrivateKey: ecKey, KeyID: keyID}, nil
}
// ---------------------------------------------------------------------------
// WithSigning — HTTP middleware wrapper for RFC 9421 response signing
// ---------------------------------------------------------------------------
// WithSigning wraps h with an HTTP middleware that adds RFC 9421 HTTP Message
// Signatures to every response. The Signature-Input and Signature headers are
// computed over @status, content-type, and x-ucp-timestamp, signed with the
// configured ECDSA P-256 key.
//
// Mount it on any existing UCP handler:
//
// mux.Handle("/ucp/v1/carts", ucp.WithSigning(ucp.CartHandler(pool), cfg))
func WithSigning(h http.Handler, cfg *SigningConfig) http.Handler {
if cfg == nil || cfg.PrivateKey == nil {
return h // pass through without signing
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sw := &signedWriter{
ResponseWriter: w,
cfg: cfg,
}
h.ServeHTTP(sw, r)
})
}
// ---------------------------------------------------------------------------
// signedWriter — response interceptor that adds signature headers
// ---------------------------------------------------------------------------
type signedWriter struct {
http.ResponseWriter
cfg *SigningConfig
statusCode int
wroteHeader bool
}
func (w *signedWriter) WriteHeader(statusCode int) {
if w.wroteHeader {
return
}
w.wroteHeader = true
w.statusCode = statusCode
created := time.Now().Unix()
w.ResponseWriter.Header().Set("x-ucp-timestamp", strconv.FormatInt(created, 10))
// Add signature headers before flushing.
w.addSignatureHeaders(created)
w.ResponseWriter.WriteHeader(statusCode)
}
func (w *signedWriter) Write(p []byte) (int, error) {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
return w.ResponseWriter.Write(p)
}
// addSignatureHeaders computes and writes the Signature-Input and Signature
// headers per RFC 9421 §3.2 / §3.3.
func (w *signedWriter) addSignatureHeaders(created int64) {
contentType := w.ResponseWriter.Header().Get("Content-Type")
sigTimestamp := strconv.FormatInt(created, 10)
// Covered components (in order). RFC 9421 §3.2: derived component names
// (@status) are bare identifiers; HTTP header field names are sf-strings
// and MUST be quoted.
components := []string{
"@status",
`"content-type"`,
`"x-ucp-timestamp"`,
}
// Build the signature base string (RFC 9421 §2.2).
var base strings.Builder
base.WriteString(fmt.Sprintf(`"@status": %d`, w.statusCode))
base.WriteByte('\n')
base.WriteString(fmt.Sprintf(`"content-type": %s`, contentType))
base.WriteByte('\n')
base.WriteString(fmt.Sprintf(`"x-ucp-timestamp": %s`, sigTimestamp))
base.WriteByte('\n')
// Append the signature-params pseudo-line (RFC 9421 §2.2).
compList := strings.Join(components, " ")
base.WriteString(fmt.Sprintf(`"@signature-params": (%s);created=%d;keyid=%q;alg="ecdsa-p256"`,
compList, created, w.cfg.KeyID))
// Sign the SHA-256 digest.
digest := sha256.Sum256([]byte(base.String()))
r, s, err := ecdsa.Sign(rand.Reader, w.cfg.PrivateKey, digest[:])
if err != nil {
// Fail open — response is sent without signature headers.
return
}
// ECDSA P-256 signature raw bytes: r||s concatenation, each 32 bytes.
rBytes := r.Bytes()
sBytes := s.Bytes()
rawSig := make([]byte, 64)
copy(rawSig[32-len(rBytes):32], rBytes)
copy(rawSig[64-len(sBytes):64], sBytes)
sigB64 := base64.RawURLEncoding.EncodeToString(rawSig)
// Signature-Input: sig1=(components);created=N;keyid="...";alg="ecdsa-p256"
sigInput := fmt.Sprintf(`sig1=(%s);created=%d;keyid=%q;alg="ecdsa-p256"`,
compList, created, w.cfg.KeyID)
// Signature: sig1=:base64url:
sigValue := fmt.Sprintf("sig1=:%s:", sigB64)
w.ResponseWriter.Header().Set("Signature-Input", sigInput)
w.ResponseWriter.Header().Set("Signature", sigValue)
}
-173
View File
@@ -1,173 +0,0 @@
package ucp
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestSigningConfig_FromPEM(t *testing.T) {
// PEM from the generated key pair (openssl ecparam -genkey -name prime256v1)
pemData := `-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIDTssEctnrEqym80fx2jEVolTW+tWrHyo8fmbi8hMFWmoAoGCCqGSM49
AwEHoUQDQgAEpnPksDSSMcNgUYT4++Z6XuS0V2p6TNMSjWBsgXOu7mo5Esoux+Cm
y+C84ty5VUAdEpoDyNfWMeaAlHKGo+FjiQ==
-----END EC PRIVATE KEY-----`
cfg, err := NewSigningConfigFromPEM([]byte(pemData), "k6n-ecdsa-2026")
if err != nil {
t.Fatalf("NewSigningConfigFromPEM failed: %v", err)
}
if cfg.KeyID != "k6n-ecdsa-2026" {
t.Fatalf("expected key ID 'k6n-ecdsa-2026', got %q", cfg.KeyID)
}
if cfg.PrivateKey == nil {
t.Fatal("expected non-nil private key")
}
if cfg.PrivateKey.Curve.Params().Name != "P-256" {
t.Fatalf("expected P-256 curve, got %s", cfg.PrivateKey.Curve.Params().Name)
}
}
func TestSigningConfig_InvalidPEM(t *testing.T) {
_, err := NewSigningConfigFromPEM([]byte("not a pem"), "test")
if err == nil {
t.Fatal("expected error for invalid PEM")
}
}
func TestWithSigning_AddsHeaders(t *testing.T) {
// Load the test key.
cfg := mustLoadTestKey(t)
// A simple handler that returns a JSON response.
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
wrapped := WithSigning(handler, cfg)
req := httptest.NewRequest("GET", "/test", nil)
rec := httptest.NewRecorder()
wrapped.ServeHTTP(rec, req)
// Check the response body.
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
// Check that signature headers are present.
sigInput := rec.Header().Get("Signature-Input")
sig := rec.Header().Get("Signature")
ts := rec.Header().Get("x-ucp-timestamp")
if sigInput == "" {
t.Fatal("expected Signature-Input header")
}
if sig == "" {
t.Fatal("expected Signature header")
}
if ts == "" {
t.Fatal("expected x-ucp-timestamp header")
}
// Verify the signature input format.
if !strings.HasPrefix(sigInput, `sig1=(@status "content-type" "x-ucp-timestamp");`) {
t.Fatalf("unexpected Signature-Input format: %q", sigInput)
}
if !strings.Contains(sigInput, `keyid="k6n-ecdsa-2026"`) {
t.Fatalf("Signature-Input missing keyid: %q", sigInput)
}
if !strings.Contains(sigInput, `alg="ecdsa-p256"`) {
t.Fatalf("Signature-Input missing alg: %q", sigInput)
}
// Verify the signature format.
if !strings.HasPrefix(sig, "sig1=:") || !strings.HasSuffix(sig, ":") {
t.Fatalf("unexpected Signature format: %q", sig)
}
}
func TestWithSigning_NilConfig(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
wrapped := WithSigning(handler, nil)
req := httptest.NewRequest("GET", "/test", nil)
rec := httptest.NewRecorder()
wrapped.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
if rec.Header().Get("Signature-Input") != "" {
t.Fatal("expected no Signature-Input when signing is nil")
}
}
func TestWithSigning_DifferentStatuses(t *testing.T) {
cfg := mustLoadTestKey(t)
for _, status := range []int{200, 201, 400, 404, 500} {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
w.Write([]byte(`{}`))
})
wrapped := WithSigning(handler, cfg)
req := httptest.NewRequest("GET", "/test", nil)
rec := httptest.NewRecorder()
wrapped.ServeHTTP(rec, req)
if rec.Code != status {
t.Fatalf("expected status %d, got %d", status, rec.Code)
}
if rec.Header().Get("Signature-Input") == "" {
t.Fatalf("expected Signature-Input for status %d", status)
}
}
}
func TestWithSigning_WithUCPCartHandler(t *testing.T) {
cfg := mustLoadTestKey(t)
applier := newTestApplier()
handler := CartHandler(applier)
wrapped := WithSigning(handler, cfg)
// Create a cart through the wrapped handler.
req := httptest.NewRequest("POST", "/", nil)
rec := httptest.NewRecorder()
wrapped.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d", rec.Code)
}
// Verify signed.
if rec.Header().Get("Signature-Input") == "" {
t.Fatal("expected Signature-Input on UCP cart response")
}
if rec.Header().Get("x-ucp-timestamp") == "" {
t.Fatal("expected x-ucp-timestamp on UCP cart response")
}
}
// mustLoadTestKey loads the test PEM for signing tests.
func mustLoadTestKey(t testing.TB) *SigningConfig {
t.Helper()
pemData := `-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIDTssEctnrEqym80fx2jEVolTW+tWrHyo8fmbi8hMFWmoAoGCCqGSM49
AwEHoUQDQgAEpnPksDSSMcNgUYT4++Z6XuS0V2p6TNMSjWBsgXOu7mo5Esoux+Cm
y+C84ty5VUAdEpoDyNfWMeaAlHKGo+FjiQ==
-----END EC PRIVATE KEY-----`
cfg, err := NewSigningConfigFromPEM([]byte(pemData), "k6n-ecdsa-2026")
if err != nil {
t.Fatalf("mustLoadTestKey: %v", err)
}
return cfg
}
-479
View File
@@ -1,479 +0,0 @@
// Package ucp provides a Universal Commerce Protocol (UCP) REST adapter for
// the cart and checkout grain pools. It translates UCP-standard cart/checkout
// endpoints (POST /carts, GET /carts/{id}, POST /checkout-sessions, …) into
// grain pool Get/Apply calls, so any UCP-compatible platform or AI agent can
// interact with the business without a bespoke integration.
//
// Transport: REST (JSON over HTTP, following the UCP Shopping Service spec).
//
// Current capabilities:
// - Cart: create, read, update items, cancel/clear
// - Checkout: initiate session, read, update, complete, cancel
//
// Mount on any existing HTTP mux via ucp.NewCartHandler(…) and
// ucp.NewCheckoutHandler(…).
package ucp
import (
"context"
"encoding/json"
"net/http"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
"git.k6n.net/mats/go-cart-actor/pkg/order"
"git.k6n.net/mats/go-cart-actor/pkg/profile"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"google.golang.org/protobuf/proto"
)
// ---------------------------------------------------------------------------
// Applier interfaces (same pattern as the MCP packages)
// ---------------------------------------------------------------------------
// CartApplier is the minimal grain-pool interface the UCP cart adapter needs.
type CartApplier interface {
Get(ctx context.Context, id uint64) (*cart.CartGrain, error)
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[cart.CartGrain], error)
}
// CheckoutApplier is the minimal grain-pool interface the UCP checkout adapter needs.
type CheckoutApplier interface {
Get(ctx context.Context, id uint64) (*checkout.CheckoutGrain, error)
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[checkout.CheckoutGrain], error)
}
// OrderReadApplier is the minimal grain-pool interface the UCP order adapter
// needs to read and mutate order grains.
type OrderReadApplier interface {
Get(ctx context.Context, id uint64) (*order.OrderGrain, error)
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[order.OrderGrain], error)
}
// ProfileApplier is the minimal grain-pool interface the UCP customer adapter needs.
type ProfileApplier interface {
Get(ctx context.Context, id uint64) (*profile.ProfileGrain, error)
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error)
}
// OrderApplier is the interface the UCP checkout adapter uses to create a real
// order from a completed checkout session. It abstracts over the transport
// (direct grain pool, HTTP, etc.) so the adapter is portable.
//
// The checkout adapter calls CreateOrder in handleCompleteCheckout when the
// optional order applier is configured. Without it, the endpoint falls back to
// a simplified no-op order reference.
type OrderApplier interface {
// CreateOrder creates a real order from the checkout session after payment
// has been completed. It returns the order ID string.
//
// checkoutID — the checkout grain's numeric id
// g — the checkout grain's full state (items, deliveries, contact, …)
// provider — payment provider that settled the payment (e.g. "adyen", "klarna")
// reference — PSP / payment reference
// currency — ISO 4217 currency code (e.g. "SEK")
// country — ISO 3166-1 alpha-2 country code (e.g. "SE")
CreateOrder(ctx context.Context, checkoutID uint64, g *checkout.CheckoutGrain, provider, reference, currency, country string) (string, error)
}
// ---------------------------------------------------------------------------
// UCP Cart types (mapped from the UCP Shopping Service REST spec)
// ---------------------------------------------------------------------------
// CreateCartRequest is the body for POST /carts. Optional — when empty, a fresh
// empty cart is created. When items are supplied the cart is seeded.
type CreateCartRequest struct {
Items []CartItemInput `json:"items,omitempty"`
}
// UpdateCartRequest is the body for PUT /carts/{id}. It carries the full or
// partial cart state to replace.
type UpdateCartRequest struct {
Items []CartItemInput `json:"items,omitempty"`
Vouchers []VoucherInput `json:"vouchers,omitempty"`
UserId *string `json:"userId,omitempty"`
Country string `json:"country,omitempty"`
}
// CartItemInput is the UCP wire format for an item being added/updated.
type CartItemInput struct {
Sku string `json:"sku"`
Quantity int `json:"quantity,omitempty"`
Name string `json:"name,omitempty"`
Price int64 `json:"price,omitempty"` // inc-vat in minor units (öre)
TaxRate float64 `json:"taxRate,omitempty"` // e.g. 25 or 12.5
Image string `json:"image,omitempty"`
StoreId *string `json:"storeId,omitempty"`
Children []CartItemInput `json:"children,omitempty"`
Fields map[string]string `json:"customFields,omitempty"`
}
// VoucherInput is the UCP wire format for a voucher code to apply.
type VoucherInput struct {
Code string `json:"code"`
Value int64 `json:"value,omitempty"`
}
// CartResponse is the UCP cart response body.
type CartResponse struct {
Id string `json:"id"`
Items []CartItem `json:"items,omitempty"`
Totals Totals `json:"totals"`
Status string `json:"status"`
}
// CartItem is the UCP wire format for a cart line item in responses.
type CartItem struct {
Sku string `json:"sku"`
Quantity int `json:"quantity"`
Name string `json:"name,omitempty"`
UnitPrice int64 `json:"unitPrice"` // inc-vat minor units
TotalPrice int64 `json:"totalPrice"` // inc-vat minor units
TaxRate int `json:"taxRate"`
Image string `json:"image,omitempty"`
ItemId uint32 `json:"itemId,omitempty"`
Fields map[string]string `json:"customFields,omitempty"`
}
// Totals is the UCP wire format for price breakdown.
type Totals struct {
TotalIncVat int64 `json:"totalIncVat"`
TotalExVat int64 `json:"totalExVat"`
TotalVat int64 `json:"totalVat"`
Currency string `json:"currency"`
Discount int64 `json:"discount,omitempty"`
}
// ---------------------------------------------------------------------------
// UCP Checkout types
// ---------------------------------------------------------------------------
// CreateCheckoutRequest is the body for POST /checkout-sessions.
type CreateCheckoutRequest struct {
CartId string `json:"cartId"`
Items []CartItemInput `json:"items,omitempty"`
LineItems []CartItemInput `json:"line_items,omitempty"`
Country string `json:"country,omitempty"`
Currency string `json:"currency,omitempty"`
CustomerId string `json:"customerId,omitempty"` // profile ID for identity linking
}
// UpdateCheckoutRequest is the body for PUT /checkout-sessions/{id}.
type UpdateCheckoutRequest struct {
Delivery *DeliveryInput `json:"delivery,omitempty"`
Contact *ContactInput `json:"contact,omitempty"`
PickupPoint *PickupPointInput `json:"pickupPoint,omitempty"`
}
// DeliveryInput carries a delivery option for a checkout session.
type DeliveryInput struct {
Provider string `json:"provider"`
ItemIds []uint32 `json:"itemIds,omitempty"`
}
// ContactInput carries contact details for a checkout session.
type ContactInput struct {
Email *string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
Name *string `json:"name,omitempty"`
PostalCode *string `json:"postalCode,omitempty"`
}
// PickupPointInput carries a pickup point for a delivery.
type PickupPointInput struct {
DeliveryId uint32 `json:"deliveryId"`
Id string `json:"id"`
Name *string `json:"name,omitempty"`
Address *string `json:"address,omitempty"`
City *string `json:"city,omitempty"`
Zip *string `json:"zip,omitempty"`
}
// CompleteCheckoutRequest is the body for POST /checkout-sessions/{id}/complete.
type CompleteCheckoutRequest struct {
PaymentToken string `json:"paymentToken,omitempty"`
Provider string `json:"provider,omitempty"`
Currency string `json:"currency,omitempty"`
Country string `json:"country,omitempty"`
CustomerId string `json:"customerId,omitempty"` // profile ID for identity linking
}
// CheckoutResponse is the UCP checkout session response.
type CheckoutResponse struct {
Id string `json:"id"`
CartId string `json:"cartId"`
Status string `json:"status"`
Items []CartItem `json:"items,omitempty"`
Totals Totals `json:"totals"`
Deliveries []DeliveryResponse `json:"deliveries,omitempty"`
Contact *ContactResponse `json:"contact,omitempty"`
OrderId *string `json:"orderId,omitempty"`
Payments []PaymentResponse `json:"payments,omitempty"`
}
// DeliveryResponse is the UCP wire format for a delivery option.
type DeliveryResponse struct {
Id uint32 `json:"id"`
Provider string `json:"provider"`
Price int64 `json:"price"`
Items []uint32 `json:"items"`
PickupPoint *PickupPointResp `json:"pickupPoint,omitempty"`
}
// PickupPointResp is the UCP wire format for a pickup point.
type PickupPointResp struct {
Id string `json:"id"`
Name *string `json:"name,omitempty"`
Address *string `json:"address,omitempty"`
City *string `json:"city,omitempty"`
Zip *string `json:"zip,omitempty"`
}
// ContactResponse is the UCP wire format for contact details.
type ContactResponse struct {
Email *string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
Name *string `json:"name,omitempty"`
PostalCode *string `json:"postalCode,omitempty"`
}
// PaymentResponse is the UCP wire format for a payment record.
type PaymentResponse struct {
Id string `json:"id"`
Provider string `json:"provider"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
Status string `json:"status"`
}
// ---------------------------------------------------------------------------
// UCP Order types
// ---------------------------------------------------------------------------
// OrderResponse is the UCP order response body.
type OrderResponse struct {
OrderId string `json:"orderId"`
Reference string `json:"reference,omitempty"`
CartId string `json:"cartId,omitempty"`
Status string `json:"status"`
Currency string `json:"currency,omitempty"`
TotalAmount int64 `json:"totalAmount"`
TotalTax int64 `json:"totalTax"`
Lines []OrderLineResp `json:"lines,omitempty"`
Payments []OrderPaymentResp `json:"payments,omitempty"`
Fulfillments []OrderFulfillmentResp `json:"fulfillments,omitempty"`
Returns []OrderReturnResp `json:"returns,omitempty"`
Refunds []OrderRefundResp `json:"refunds,omitempty"`
CapturedAmount int64 `json:"capturedAmount"`
RefundedAmount int64 `json:"refundedAmount"`
CustomerEmail string `json:"customerEmail,omitempty"`
CustomerName string `json:"customerName,omitempty"`
PlacedAt string `json:"placedAt,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
// OrderLineResp represents a single order line item in responses.
type OrderLineResp struct {
Reference string `json:"reference"`
Sku string `json:"sku"`
Name string `json:"name,omitempty"`
Quantity int `json:"quantity"`
UnitPrice int64 `json:"unitPrice"`
TaxRate int `json:"taxRate"`
TotalAmount int64 `json:"totalAmount"`
TotalTax int64 `json:"totalTax"`
Fulfilled int `json:"fulfilled,omitempty"`
}
// OrderPaymentResp represents a payment record on an order.
type OrderPaymentResp struct {
Provider string `json:"provider"`
Authorized int64 `json:"authorized"`
Captured int64 `json:"captured"`
Refunded int64 `json:"refunded"`
AuthRef string `json:"authRef,omitempty"`
CaptureRef string `json:"captureRef,omitempty"`
}
// OrderFulfillmentResp represents a shipment record.
type OrderFulfillmentResp struct {
Id string `json:"id"`
Carrier string `json:"carrier,omitempty"`
TrackingNumber string `json:"trackingNumber,omitempty"`
TrackingURI string `json:"trackingUri,omitempty"`
Lines []OrderLineEntry `json:"lines,omitempty"`
}
// OrderReturnResp represents a return request (RMA).
type OrderReturnResp struct {
Id string `json:"id"`
Reason string `json:"reason,omitempty"`
Lines []OrderLineEntry `json:"lines,omitempty"`
}
// OrderRefundResp represents a refund record.
type OrderRefundResp struct {
Provider string `json:"provider"`
Amount int64 `json:"amount"`
Reference string `json:"reference,omitempty"`
}
// OrderLineEntry is a lightweight reference+quantity pair used in fulfillment
// and return line lists.
type OrderLineEntry struct {
Reference string `json:"reference"`
Quantity int `json:"quantity"`
}
// ---------------------------------------------------------------------------
// UCP Customer types
// ---------------------------------------------------------------------------
// CustomerResponse is the UCP customer profile response body.
type CustomerResponse struct {
Id string `json:"id"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Phone string `json:"phone,omitempty"`
Language string `json:"language,omitempty"`
Currency string `json:"currency,omitempty"`
AvatarUrl string `json:"avatarUrl,omitempty"`
Addresses []AddressResponse `json:"addresses,omitempty"`
}
// AddressResponse is the UCP wire format for an address.
type AddressResponse struct {
Id uint32 `json:"id"`
Label string `json:"label,omitempty"`
FullName string `json:"fullName,omitempty"`
AddressLine1 string `json:"addressLine1"`
AddressLine2 string `json:"addressLine2,omitempty"`
City string `json:"city"`
State string `json:"state,omitempty"`
Zip string `json:"zip"`
Country string `json:"country"`
Phone string `json:"phone,omitempty"`
IsDefaultShipping bool `json:"isDefaultShipping"`
IsDefaultBilling bool `json:"isDefaultBilling"`
}
// CustomerUpdateRequest is the body for PUT /customers/{id}.
type CustomerUpdateRequest struct {
Name *string `json:"name,omitempty"`
Email *string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
Language *string `json:"language,omitempty"`
Currency *string `json:"currency,omitempty"`
AvatarUrl *string `json:"avatarUrl,omitempty"`
}
// AddAddressRequest is the body for POST /customers/{id}/addresses.
type AddAddressRequest struct {
Label string `json:"label,omitempty"`
FullName string `json:"fullName,omitempty"`
AddressLine1 string `json:"addressLine1"`
AddressLine2 string `json:"addressLine2,omitempty"`
City string `json:"city"`
State string `json:"state,omitempty"`
Zip string `json:"zip"`
Country string `json:"country"`
Phone string `json:"phone,omitempty"`
IsDefaultShipping bool `json:"isDefaultShipping,omitempty"`
IsDefaultBilling bool `json:"isDefaultBilling,omitempty"`
}
// UpdateAddressRequest is the body for PUT /customers/{id}/addresses/{addressId}.
type UpdateAddressRequest struct {
Label *string `json:"label,omitempty"`
FullName *string `json:"fullName,omitempty"`
AddressLine1 *string `json:"addressLine1,omitempty"`
AddressLine2 *string `json:"addressLine2,omitempty"`
City *string `json:"city,omitempty"`
State *string `json:"state,omitempty"`
Zip *string `json:"zip,omitempty"`
Country *string `json:"country,omitempty"`
Phone *string `json:"phone,omitempty"`
IsDefaultShipping *bool `json:"isDefaultShipping,omitempty"`
IsDefaultBilling *bool `json:"isDefaultBilling,omitempty"`
}
// ---------------------------------------------------------------------------
// UCP Profile handler (/.well-known/ucp)
// ---------------------------------------------------------------------------
// ProfileData is the UCP business profile served at /.well-known/ucp.
type ProfileData struct {
UCP Profile `json:"ucp"`
}
// Profile is the core UCP profile object.
type Profile struct {
Version string `json:"version"`
Business BusinessInfo `json:"business,omitempty"`
Services map[string][]ServiceBinding `json:"services"`
Capabilities map[string][]CapabilityDecl `json:"capabilities"`
PaymentHandlers map[string][]PaymentHandlerDecl `json:"payment_handlers,omitempty"`
SupportedVersions []string `json:"supported_versions,omitempty"`
}
// BusinessInfo describes the business behind a UCP profile.
type BusinessInfo struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Domain string `json:"domain"`
}
// ServiceBinding describes a transport binding for a service namespace.
type ServiceBinding struct {
Version string `json:"version"`
Spec string `json:"spec"`
Transport string `json:"transport"`
Schema string `json:"schema"`
Endpoint string `json:"endpoint"`
}
// CapabilityDecl describes a single capability version.
type CapabilityDecl struct {
Version string `json:"version"`
Spec string `json:"spec"`
Schema string `json:"schema"`
Extends string `json:"extends,omitempty"`
Description string `json:"description,omitempty"`
Operations []OpDef `json:"operations,omitempty"`
}
// OpDef describes an operation method+path for a capability.
type OpDef struct {
Method string `json:"method"`
Path string `json:"path"`
}
// PaymentHandlerDecl describes a payment handler specification.
type PaymentHandlerDecl struct {
ID string `json:"id"`
Version string `json:"version"`
Spec string `json:"spec"`
Schema string `json:"schema"`
Description string `json:"description,omitempty"`
}
// ---------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------
// writeJSON encodes v as JSON and writes it with the given status code.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
// errorBody is a simple {"error": "…"} response.
type errorBody struct {
Error string `json:"error"`
}
// writeError writes a JSON error response.
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, errorBody{Error: msg})
}
-7
View File
@@ -147,13 +147,6 @@ func (s *DiskStorage[V]) AppendMutations(id uint64, msg ...proto.Message) error
return nil return nil
} else { } else {
path := s.logPath(id) path := s.logPath(id)
// Ensure the parent directory exists (e.g. first write to a new PVC mount).
// MkdirAll is a no-op when the directory already exists, so it's safe to
// call on every AppendMutations without extra stat overhead.
if err := os.MkdirAll(s.path, 0755); err != nil {
log.Printf("failed to create event log directory %s: %v", s.path, err)
return err
}
fh, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) fh, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil { if err != nil {
log.Printf("failed to open event log file: %v", err) log.Printf("failed to open event log file: %v", err)
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"net" "net"
"time" "time"
messages "git.k6n.net/mats/go-cart-actor/proto/control" messages "git.k6n.net/go-cart-actor/proto/control"
"go.opentelemetry.io/contrib/bridges/otelslog" "go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/otel" "go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/attribute"
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"context" "context"
"testing" "testing"
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart" cart_messages "git.k6n.net/go-cart-actor/proto/cart"
control_plane_messages "git.k6n.net/mats/go-cart-actor/proto/control" control_plane_messages "git.k6n.net/go-cart-actor/proto/control"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
-50
View File
@@ -1,50 +0,0 @@
package actor
import "sync"
// keyedMutex provides one logical mutex per key (grain id). It is the
// mechanism that enforces the core actor guarantee: a single grain only ever
// processes one message (spawn / mutation / read) at a time, while distinct
// grains run fully in parallel.
//
// Locks are reference counted and removed once no caller holds or waits on
// them, so memory stays proportional to in-flight grains rather than the total
// number of grains ever touched.
type keyedMutex struct {
mu sync.Mutex
locks map[uint64]*keyedMutexEntry
}
type keyedMutexEntry struct {
mu sync.Mutex
refs int
}
func newKeyedMutex() *keyedMutex {
return &keyedMutex{locks: make(map[uint64]*keyedMutexEntry)}
}
// lock acquires the mutex for id and returns an unlock function. The returned
// function must be called exactly once.
func (k *keyedMutex) lock(id uint64) func() {
k.mu.Lock()
entry, ok := k.locks[id]
if !ok {
entry = &keyedMutexEntry{}
k.locks[id] = entry
}
entry.refs++
k.mu.Unlock()
entry.mu.Lock()
return func() {
entry.mu.Unlock()
k.mu.Lock()
entry.refs--
if entry.refs == 0 {
delete(k.locks, id)
}
k.mu.Unlock()
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ package actor
import ( import (
"log" "log"
"git.k6n.net/mats/slask-finder/pkg/messaging" "github.com/matst80/slask-finder/pkg/messaging"
amqp "github.com/rabbitmq/amqp091-go" amqp "github.com/rabbitmq/amqp091-go"
) )
+63 -2
View File
@@ -41,14 +41,16 @@ type MutationRegistry interface {
Create(typeName string) (proto.Message, bool) Create(typeName string) (proto.Message, bool)
GetTypeName(msg proto.Message) (string, bool) GetTypeName(msg proto.Message) (string, bool)
RegisterProcessor(processor ...MutationProcessor) RegisterProcessor(processor ...MutationProcessor)
//GetStorageEvent(msg proto.Message) StorageEvent RegisterTrigger(trigger ...TriggerHandler)
//FromStorageEvent(event StorageEvent) (proto.Message, error) SetEventChannel(ch chan<- ApplyResult)
} }
type ProtoMutationRegistry struct { type ProtoMutationRegistry struct {
mutationRegistryMu sync.RWMutex mutationRegistryMu sync.RWMutex
mutationRegistry map[reflect.Type]MutationHandler mutationRegistry map[reflect.Type]MutationHandler
triggers map[reflect.Type][]TriggerHandler
processors []MutationProcessor processors []MutationProcessor
eventChannel chan<- ApplyResult
} }
var ( var (
@@ -84,6 +86,26 @@ func WithTotals() MutationOption {
} }
} }
type TriggerHandler interface {
Handle(state any, msg proto.Message) []proto.Message
Name() string
Type() reflect.Type
}
type RegisteredTrigger[V any, I proto.Message] struct {
name string
handler func(state any, msg proto.Message) []proto.Message
msgType reflect.Type
}
func NewTrigger[V any, I proto.Message](name string, handler func(state any, msg proto.Message) []proto.Message) *RegisteredTrigger[V, I] {
return &RegisteredTrigger[V, I]{
name: name,
handler: handler,
msgType: reflect.TypeOf((*I)(nil)).Elem(),
}
}
type MutationHandler interface { type MutationHandler interface {
Handle(state any, msg proto.Message) error Handle(state any, msg proto.Message) error
Name() string Name() string
@@ -145,6 +167,7 @@ func NewMutationRegistry() MutationRegistry {
return &ProtoMutationRegistry{ return &ProtoMutationRegistry{
mutationRegistry: make(map[reflect.Type]MutationHandler), mutationRegistry: make(map[reflect.Type]MutationHandler),
mutationRegistryMu: sync.RWMutex{}, mutationRegistryMu: sync.RWMutex{},
triggers: make(map[reflect.Type][]TriggerHandler),
processors: make([]MutationProcessor, 0), processors: make([]MutationProcessor, 0),
} }
} }
@@ -162,6 +185,24 @@ func (r *ProtoMutationRegistry) RegisterMutations(handlers ...MutationHandler) {
} }
} }
func (r *ProtoMutationRegistry) RegisterTrigger(triggers ...TriggerHandler) {
r.mutationRegistryMu.Lock()
defer r.mutationRegistryMu.Unlock()
for _, trigger := range triggers {
existingTriggers, ok := r.triggers[trigger.Type()]
if !ok {
r.triggers[trigger.Type()] = []TriggerHandler{trigger}
} else {
r.triggers[trigger.Type()] = append(existingTriggers, trigger)
}
}
}
func (r *ProtoMutationRegistry) SetEventChannel(ch chan<- ApplyResult) {
r.eventChannel = ch
}
func (r *ProtoMutationRegistry) GetTypeName(msg proto.Message) (string, bool) { func (r *ProtoMutationRegistry) GetTypeName(msg proto.Message) (string, bool) {
r.mutationRegistryMu.RLock() r.mutationRegistryMu.RLock()
defer r.mutationRegistryMu.RUnlock() defer r.mutationRegistryMu.RUnlock()
@@ -244,6 +285,25 @@ func (r *ProtoMutationRegistry) Apply(ctx context.Context, grain any, msg ...pro
if err != nil { if err != nil {
msgSpan.RecordError(err) msgSpan.RecordError(err)
} }
if r.eventChannel != nil {
go func() {
defer func() {
if r := recover(); r != nil {
// Handle panic from sending to closed channel
log.Printf("event channel closed: %v", r)
}
}()
for _, tr := range r.triggers[rt] {
for _, msg := range tr.Handle(grain, m) {
select {
case r.eventChannel <- msg:
default:
// Channel full or no receiver, skip to avoid blocking
}
}
}
}()
}
results = append(results, ApplyResult{Error: err, Type: rt.Name(), Mutation: m}) results = append(results, ApplyResult{Error: err, Type: rt.Name(), Mutation: m})
} }
msgSpan.End() msgSpan.End()
@@ -266,6 +326,7 @@ func (r *ProtoMutationRegistry) Apply(ctx context.Context, grain any, msg ...pro
return results, res.Error return results, res.Error
} }
} }
return results, nil return results, nil
} }
+98 -25
View File
@@ -5,8 +5,9 @@ import (
"reflect" "reflect"
"slices" "slices"
"testing" "testing"
"time"
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart" cart_messages "git.k6n.net/go-cart-actor/proto/cart"
) )
type cartState struct { type cartState struct {
@@ -104,31 +105,103 @@ func TestRegisteredMutationBasics(t *testing.T) {
} }
} }
// func TestConcurrentSafeRegistrationLookup(t *testing.T) { func TestEventChannel(t *testing.T) {
// // This test is light-weight; it ensures locks don't deadlock under simple concurrent access. reg := NewMutationRegistry().(*ProtoMutationRegistry)
// reg := NewMutationRegistry().(*ProtoMutationRegistry)
// mut := NewMutation[cartState, *messages.Noop](
// func(state *cartState, msg *messages.Noop) error { state.calls++; return nil },
// func() *messages.Noop { return &messages.Noop{} },
// )
// reg.RegisterMutations(mut)
// done := make(chan struct{}) addItemMutation := NewMutation(
// const workers = 25 func(state *cartState, msg *cart_messages.AddItem) error {
// for i := 0; i < workers; i++ { state.calls++
// go func() { return nil
// for j := 0; j < 100; j++ { },
// _, _ = reg.Create("Noop") )
// _, _ = reg.GetTypeName(&messages.Noop{})
// _ = reg.Apply(&cartState{}, &messages.Noop{})
// }
// done <- struct{}{}
// }()
// }
// for i := 0; i < workers; i++ { reg.RegisterMutations(addItemMutation)
// <-done
// } eventCh := make(chan ApplyResult, 10)
// } reg.SetEventChannel(eventCh)
state := &cartState{}
add := &cart_messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
results, err := reg.Apply(context.Background(), state, add)
if err != nil {
t.Fatalf("Apply returned error: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
// Receive from channel with timeout
select {
case res := <-eventCh:
if res.Type != "AddItem" {
t.Fatalf("expected type AddItem, got %s", res.Type)
}
if res.Error != nil {
t.Fatalf("expected no error, got %v", res.Error)
}
case <-time.After(time.Second):
t.Fatalf("expected to receive event on channel within timeout")
}
}
func TestEventChannelClosed(t *testing.T) {
reg := NewMutationRegistry().(*ProtoMutationRegistry)
addItemMutation := NewMutation(
func(state *cartState, msg *cart_messages.AddItem) error {
state.calls++
return nil
},
)
reg.RegisterMutations(addItemMutation)
eventCh := make(chan ApplyResult, 10)
reg.SetEventChannel(eventCh)
close(eventCh) // Close the channel to simulate external close
state := &cartState{}
add := &cart_messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
// This should not panic due to recover in goroutine
results, err := reg.Apply(context.Background(), state, add)
if err != nil {
t.Fatalf("Apply returned error: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
// Test passes if no panic occurs
}
func TestEventChannelUnbufferedNoListener(t *testing.T) {
reg := NewMutationRegistry().(*ProtoMutationRegistry)
addItemMutation := NewMutation(
func(state *cartState, msg *cart_messages.AddItem) error {
state.calls++
return nil
},
)
reg.RegisterMutations(addItemMutation)
eventCh := make(chan ApplyResult) // unbuffered
reg.SetEventChannel(eventCh)
// No goroutine reading from eventCh
state := &cartState{}
add := &cart_messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
results, err := reg.Apply(context.Background(), state, add)
if err != nil {
t.Fatalf("Apply returned error: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
// Since no listener, the send should go to default and not block
// Test passes if Apply completes without hanging
}
// Helpers // Helpers
+4 -35
View File
@@ -24,10 +24,6 @@ type SimpleGrainPool[V any] struct {
ttl time.Duration ttl time.Duration
poolSize int poolSize int
// grainLocks serializes spawn + mutation + read per grain id so that each
// grain processes a single message at a time (the actor guarantee).
grainLocks *keyedMutex
// Cluster coordination -------------------------------------------------- // Cluster coordination --------------------------------------------------
hostname string hostname string
remoteMu sync.RWMutex remoteMu sync.RWMutex
@@ -43,9 +39,6 @@ type GrainPoolConfig[V any] struct {
Hostname string Hostname string
Spawn func(ctx context.Context, id uint64) (Grain[V], error) Spawn func(ctx context.Context, id uint64) (Grain[V], error)
SpawnHost func(host string) (Host[V], error) SpawnHost func(host string) (Host[V], error)
// Destroy is an optional cleanup hook called when a grain is evicted on TTL
// (e.g. to detach inventory subscriptions wired into its mutations). Leave
// nil when there is nothing to clean up — purge() skips a nil Destroy.
Destroy func(grain Grain[V]) error Destroy func(grain Grain[V]) error
TTL time.Duration TTL time.Duration
PoolSize int PoolSize int
@@ -66,7 +59,6 @@ func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V],
hostname: config.Hostname, hostname: config.Hostname,
remoteOwners: make(map[uint64]Host[V]), remoteOwners: make(map[uint64]Host[V]),
remoteHosts: make(map[string]Host[V]), remoteHosts: make(map[string]Host[V]),
grainLocks: newKeyedMutex(),
} }
p.purgeTicker = time.NewTicker(time.Minute) p.purgeTicker = time.NewTicker(time.Minute)
@@ -99,10 +91,8 @@ func (p *SimpleGrainPool[V]) purge() {
for id, grain := range p.grains { for id, grain := range p.grains {
if grain.GetLastAccess().Before(purgeLimit) { if grain.GetLastAccess().Before(purgeLimit) {
purgedIds = append(purgedIds, id) purgedIds = append(purgedIds, id)
if p.destroy != nil { if err := p.destroy(grain); err != nil {
if err := p.destroy(grain); err != nil { log.Printf("failed to destroy grain %d: %v", id, err)
log.Printf("failed to destroy grain %d: %v", id, err)
}
} }
delete(p.grains, id) delete(p.grains, id)
@@ -197,17 +187,7 @@ func (p *SimpleGrainPool[V]) AddRemote(host string) (Host[V], error) {
return nil, err return nil, err
} }
// Re-check under the write lock: spawnHost opened a real connection above
// without holding the lock, so a concurrent AddRemote for the same host may
// have already registered one. Keep the existing entry and close ours to
// avoid leaking the gRPC connection / HTTP transport (and its file handles).
p.remoteMu.Lock() p.remoteMu.Lock()
if existing, found := p.remoteHosts[host]; found {
p.remoteMu.Unlock()
go remote.Close()
return existing, nil
}
p.remoteHosts[host] = remote p.remoteHosts[host] = remote
p.remoteMu.Unlock() p.remoteMu.Unlock()
// connectedRemotes.Set(float64(p.RemoteCount())) // connectedRemotes.Set(float64(p.RemoteCount()))
@@ -241,6 +221,7 @@ func (p *SimpleGrainPool[V]) RemoveHost(host string) {
remote, exists := p.remoteHosts[host] remote, exists := p.remoteHosts[host]
if exists { if exists {
go remote.Close()
delete(p.remoteHosts, host) delete(p.remoteHosts, host)
} }
count := 0 count := 0
@@ -253,7 +234,6 @@ func (p *SimpleGrainPool[V]) RemoveHost(host string) {
log.Printf("Removing host %s, grains: %d", host, count) log.Printf("Removing host %s, grains: %d", host, count)
p.remoteMu.Unlock() p.remoteMu.Unlock()
// Close once, outside the lock.
if exists { if exists {
remote.Close() remote.Close()
} }
@@ -295,9 +275,7 @@ func (p *SimpleGrainPool[V]) pingLoop(remote Host[V]) {
if !remote.Ping() { if !remote.Ping() {
if !remote.IsHealthy() { if !remote.IsHealthy() {
log.Printf("Remote %s unhealthy, removing", remote.Name()) log.Printf("Remote %s unhealthy, removing", remote.Name())
// Remove only this host. Previously this called p.Close(), p.Close()
// which tore down every remote connection and stopped the
// purge ticker for the whole pool.
p.RemoveHost(remote.Name()) p.RemoveHost(remote.Name())
return return
} }
@@ -419,12 +397,6 @@ func (p *SimpleGrainPool[V]) getOrClaimGrain(ctx context.Context, id uint64) (Gr
// Apply applies a mutation to a grain. // Apply applies a mutation to a grain.
func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[V], error) { func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[V], error) {
// Serialize all access to this grain: spawn, mutation handlers and the
// final state read happen atomically with respect to other callers of the
// same id. Different ids never contend.
unlock := p.grainLocks.lock(id)
defer unlock()
grain, err := p.getOrClaimGrain(ctx, id) grain, err := p.getOrClaimGrain(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -457,9 +429,6 @@ func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...p
// Get returns the current state of a grain. // Get returns the current state of a grain.
func (p *SimpleGrainPool[V]) Get(ctx context.Context, id uint64) (*V, error) { func (p *SimpleGrainPool[V]) Get(ctx context.Context, id uint64) (*V, error) {
unlock := p.grainLocks.lock(id)
defer unlock()
grain, err := p.getOrClaimGrain(ctx, id) grain, err := p.getOrClaimGrain(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
-220
View File
@@ -1,220 +0,0 @@
// Package backofficeadmin is the commerce control-plane admin surface for the
// cart-actor system: read-only cart/checkout history (replayed from event logs),
// inventory CRUD (Redis), promotions/vouchers JSON, and a WebSocket feed of cart
// mutations consumed from RabbitMQ.
//
// It is extracted from the former cmd/backoffice main package so the same admin
// surface can run standalone (cmd/backoffice) or be mounted into the all-in-one
// backoffice host. Because it imports the cart/checkout domain (and the private
// git.k6n.net module), the host only compiles it into its implementation-specific
// (commerce) build variant.
package backofficeadmin
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"time"
actor "git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
"git.k6n.net/mats/go-cart-actor/pkg/profile"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
"git.k6n.net/mats/slask-finder/pkg/messaging"
amqp "github.com/rabbitmq/amqp091-go"
"github.com/redis/go-redis/v9"
)
// CartFileInfo is the metadata returned by the cart listing endpoint.
type CartFileInfo struct {
ID string `json:"id"`
CartId cart.CartId `json:"cartId"`
Size int64 `json:"size"`
Modified time.Time `json:"modified"`
}
// CheckoutFileInfo is the metadata returned by the checkout listing endpoint.
type CheckoutFileInfo struct {
ID string `json:"id"`
CheckoutId checkout.CheckoutId `json:"checkoutId"`
Size int64 `json:"size"`
Modified time.Time `json:"modified"`
}
// Config parameterizes the commerce admin App.
type Config struct {
// DataDir holds the cart event logs (default "data").
DataDir string
// CheckoutDataDir holds the checkout event logs (default "checkout-data").
CheckoutDataDir string
// ProfileDataDir holds the profile event logs (optional, default "" — customer endpoints disabled).
ProfileDataDir string
// RedisAddress / RedisPassword reach the inventory store.
RedisAddress string
RedisPassword string
}
// App is the commerce admin control plane. Construct with New, register its
// HTTP surface with RegisterRoutes, run background work with Start.
type App struct {
fs *FileServer
hub *Hub
rdb *redis.Client
inv *inventory.RedisInventoryService
cs *CustomerServer
}
// New constructs the commerce admin: it opens the Redis inventory service, the
// cart/checkout disk event-log stores, the file server, and the WebSocket hub.
// It does not register routes (RegisterRoutes), start background work (Start),
// or open a listener.
func New(cfg Config) (*App, error) {
if cfg.DataDir == "" {
cfg.DataDir = "data"
}
if cfg.CheckoutDataDir == "" {
cfg.CheckoutDataDir = "checkout-data"
}
rdb := redis.NewClient(&redis.Options{
Addr: cfg.RedisAddress,
Password: cfg.RedisPassword,
DB: 0,
})
inventoryService, err := inventory.NewRedisInventoryService(rdb)
if err != nil {
return nil, err
}
_ = os.MkdirAll(cfg.DataDir, 0755)
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
diskStorage := actor.NewDiskStorage[cart.CartGrain](cfg.DataDir, reg)
_ = os.MkdirAll(cfg.CheckoutDataDir, 0755)
regCheckout := checkout.NewCheckoutMutationRegistry(checkout.NewCheckoutMutationContext())
diskStorageCheckout := actor.NewDiskStorage[checkout.CheckoutGrain](cfg.CheckoutDataDir, regCheckout)
// Optional customer/profile storage.
var customerSrv *CustomerServer
if cfg.ProfileDataDir != "" {
_ = os.MkdirAll(cfg.ProfileDataDir, 0755)
regProfile := profile.NewProfileMutationRegistry()
profileStorage := actor.NewDiskStorage[profile.ProfileGrain](cfg.ProfileDataDir, regProfile)
customerSrv = NewCustomerServer(cfg.ProfileDataDir, profileStorage)
}
return &App{
fs: NewFileServer(cfg.DataDir, cfg.CheckoutDataDir, diskStorage, diskStorageCheckout),
hub: NewHub(),
rdb: rdb,
inv: inventoryService,
cs: customerSrv,
}, nil
}
// RegisterRoutes registers the commerce admin HTTP surface on mux. It does not
// apply auth or CORS — the composing host (or standalone main) wraps the mux
// with those.
func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /carts", a.fs.CartsHandler)
mux.HandleFunc("GET /cart/{id}", a.fs.CartHandler)
mux.HandleFunc("GET /checkouts", a.fs.CheckoutsHandler)
mux.HandleFunc("GET /checkout/{id}", a.fs.CheckoutHandler)
mux.HandleFunc("PUT /inventory/{locationId}/{sku}", a.updateInventory)
mux.HandleFunc("/promotions", a.fs.PromotionsHandler)
mux.HandleFunc("/vouchers", a.fs.VoucherHandler)
mux.HandleFunc("/promotion/{id}", a.fs.PromotionPartHandler)
mux.HandleFunc("/ws", a.hub.ServeWS)
// Optional customer endpoints (only mounted when ProfileDataDir is configured).
if a.cs != nil {
a.cs.RegisterRoutes(mux)
}
}
func (a *App) updateInventory(w http.ResponseWriter, r *http.Request) {
locationID := inventory.LocationID(r.PathValue("locationId"))
sku := inventory.SKU(r.PathValue("sku"))
pipe := a.rdb.Pipeline()
var payload struct {
Quantity int64 `json:"quantity"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
a.inv.UpdateInventory(r.Context(), pipe, sku, locationID, payload.Quantity)
if _, err := pipe.Exec(r.Context()); err != nil {
http.Error(w, "failed to update inventory", http.StatusInternalServerError)
return
}
if err := a.inv.SendInventoryChanged(r.Context(), sku, locationID); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}
// Start runs the WebSocket hub loop and, when conn is non-nil, the RabbitMQ
// mutation consumer that broadcasts cart mutations to connected clients. The
// background goroutines stop when ctx is cancelled.
func (a *App) Start(ctx context.Context, conn *amqp.Connection) error {
go a.hub.Run()
if conn != nil {
if err := startMutationConsumer(ctx, conn, a.hub); err != nil {
return err
}
}
return nil
}
// Shutdown releases the App's resources (the Redis client). The background
// goroutines are stopped by cancelling the ctx passed to Start.
func (a *App) Shutdown(_ context.Context) error {
return a.rdb.Close()
}
// startMutationConsumer subscribes to the cart "mutation" topic and forwards
// each message to the hub for broadcast over WebSocket. Best-effort: a full hub
// queue drops the message rather than blocking.
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.Printf("mutation consumer: channel closed")
return
}
log.Printf("mutation event: %s", string(m.Body))
if hub != nil {
select {
case hub.broadcast <- m.Body:
default:
// hub queue full: drop to avoid blocking
}
}
if err := m.Ack(false); err != nil {
log.Printf("error acknowledging message: %v", err)
}
}
}
}()
return nil
}
-200
View File
@@ -1,200 +0,0 @@
package backofficeadmin
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/profile"
)
// CustomerFileInfo is the metadata returned by the customer listing endpoint.
type CustomerFileInfo struct {
ID string `json:"id"`
Size int64 `json:"size"`
Modified time.Time `json:"modified"`
}
// CustomerServer serves backoffice admin endpoints for customer profiles.
type CustomerServer struct {
dataDir string
storage actor.LogStorage[profile.ProfileGrain]
}
// NewCustomerServer builds a CustomerServer that lists and reads profile event logs.
func NewCustomerServer(dataDir string, storage actor.LogStorage[profile.ProfileGrain]) *CustomerServer {
return &CustomerServer{
dataDir: dataDir,
storage: storage,
}
}
// RegisterRoutes mounts the customer admin endpoints on the given mux.
func (s *CustomerServer) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /customers", s.handleListCustomers)
mux.HandleFunc("GET /customer/{id}", s.handleGetCustomer)
}
// CustomerInfoResponse is the per-customer info for the listing endpoint.
type CustomerInfoResponse struct {
ID string `json:"id"`
Email string `json:"email,omitempty"`
Name string `json:"name,omitempty"`
Size int64 `json:"size"`
Modified string `json:"modified"`
}
// CustomerDetailResponse is the full customer detail including linked carts/checkouts/orders/addresses.
type CustomerDetailResponse struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Phone string `json:"phone,omitempty"`
Language string `json:"language,omitempty"`
Currency string `json:"currency,omitempty"`
AvatarUrl string `json:"avatarUrl,omitempty"`
Carts []profile.LinkedCart `json:"carts,omitempty"`
Checkouts []profile.LinkedCheckout `json:"checkouts,omitempty"`
Orders []profile.LinkedOrder `json:"orders,omitempty"`
Addresses []profile.StoredAddress `json:"addresses,omitempty"`
Size int64 `json:"size"`
Modified string `json:"modified"`
}
// handleListCustomers lists all customer profiles.
func (s *CustomerServer) handleListCustomers(w http.ResponseWriter, r *http.Request) {
list, err := s.listProfileFiles()
if err != nil {
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"count": len(list),
"customers": list,
})
}
// handleGetCustomer returns full detail for a single customer profile.
func (s *CustomerServer) handleGetCustomer(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
if idStr == "" {
writeJSON(w, http.StatusBadRequest, JsonError{Error: "missing id"})
return
}
id, ok := isValidProfileId(idStr)
if !ok {
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid id"})
return
}
// Reconstruct profile from event log.
grain := profile.NewProfileGrain(id, time.Now())
if err := s.storage.LoadEvents(r.Context(), id, grain); err != nil {
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
return
}
path := filepath.Join(s.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: "customer not found"})
return
} else if err != nil {
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
return
}
resp := CustomerDetailResponse{
ID: idStr,
Name: grain.Name,
Email: grain.Email,
Phone: grain.Phone,
Language: grain.Language,
Currency: grain.Currency,
AvatarUrl: grain.AvatarUrl,
Carts: grain.Carts,
Checkouts: grain.Checkouts,
Orders: grain.Orders,
Addresses: grain.Addresses,
Size: info.Size(),
Modified: info.ModTime().Format(time.RFC3339),
}
writeJSON(w, http.StatusOK, map[string]any{
"customer": resp,
})
}
// isValidProfileId parses a profile ID string that is either a bare decimal
// number (legacy backoffice IDs) or a base62-encoded identifier (generated by
// the UCP profile service via profile.NewProfileId). It uses profile.ParseProfileId
// for the base62 case so both formats are handled consistently.
func isValidProfileId(id string) (uint64, bool) {
// Bare decimal (legacy backoffice listing returns numeric IDs).
if nr, err := strconv.ParseUint(id, 10, 64); err == nil {
return nr, true
}
// Base62 — the format used by the UCP profile service (cmd/profile).
if pid, ok := profile.ParseProfileId(id); ok {
return uint64(pid), true
}
return 0, false
}
// listProfileFiles lists all profile event log files in the data directory.
func (s *CustomerServer) listProfileFiles() ([]CustomerInfoResponse, error) {
entries, err := os.ReadDir(s.dataDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []CustomerInfoResponse{}, nil
}
return nil, err
}
out := make([]CustomerInfoResponse, 0)
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
parts := strings.Split(name, ".")
if len(parts) < 2 || parts[1] != "events" {
continue
}
id, err := strconv.ParseUint(parts[0], 10, 64)
if err != nil {
continue
}
info, err := e.Info()
if err != nil {
continue
}
// Quick peek at the profile for email/name by loading the grain
grain := profile.NewProfileGrain(id, info.ModTime())
_ = s.storage.LoadEvents(context.Background(), id, grain)
out = append(out, CustomerInfoResponse{
ID: fmt.Sprintf("%d", id),
Email: grain.Email,
Name: grain.Name,
Size: info.Size(),
Modified: info.ModTime().Format(time.RFC3339),
})
}
sort.Slice(out, func(i, j int) bool {
return out[i].Modified > out[j].Modified
})
return out, nil
}
+25 -61
View File
@@ -5,8 +5,8 @@ import (
"sync" "sync"
"time" "time"
"git.k6n.net/mats/go-cart-actor/pkg/voucher" "git.k6n.net/go-cart-actor/pkg/voucher"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory" "github.com/matst80/go-redis-inventory/pkg/inventory"
) )
// Legacy padded [16]byte CartId and its helper methods removed. // Legacy padded [16]byte CartId and its helper methods removed.
@@ -28,38 +28,29 @@ type ItemMeta struct {
} }
type CartItem struct { type CartItem struct {
Id uint32 `json:"id"` Id uint32 `json:"id"`
ItemId uint32 `json:"itemId,omitempty"` ItemId uint32 `json:"itemId,omitempty"`
ParentId *uint32 `json:"parentId,omitempty"` ParentId *uint32 `json:"parentId,omitempty"`
Sku string `json:"sku"` Sku string `json:"sku"`
Price Price `json:"price"` Price Price `json:"price"`
TotalPrice Price `json:"totalPrice"` TotalPrice Price `json:"totalPrice"`
SellerId string `json:"sellerId,omitempty"` SellerId string `json:"sellerId,omitempty"`
OrgPrice *Price `json:"orgPrice,omitempty"` OrgPrice *Price `json:"orgPrice,omitempty"`
Cgm string `json:"cgm,omitempty"` Cgm string `json:"cgm,omitempty"`
Tax int `json:"tax"` Tax int
Stock uint16 `json:"stock"` Stock uint16 `json:"stock"`
Quantity uint16 `json:"qty"` Quantity uint16 `json:"qty"`
Discount *Price `json:"discount,omitempty"` Discount *Price `json:"discount,omitempty"`
Disclaimer string `json:"disclaimer,omitempty"` Disclaimer string `json:"disclaimer,omitempty"`
ArticleType string `json:"type,omitempty"` ArticleType string `json:"type,omitempty"`
StoreId *string `json:"storeId,omitempty"` StoreId *string `json:"storeId,omitempty"`
Meta *ItemMeta `json:"meta,omitempty"` Meta *ItemMeta `json:"meta,omitempty"`
SaleStatus string `json:"saleStatus"` SaleStatus string `json:"saleStatus"`
Marking *Marking `json:"marking,omitempty"` Marking *Marking `json:"marking,omitempty"`
// CustomFields holds optional user-supplied input fields for this line SubscriptionDetailsId string `json:"subscriptionDetailsId,omitempty"`
// (engraving text, configurator notes, ...), keyed by field name. OrderReference string `json:"orderReference,omitempty"`
CustomFields map[string]string `json:"customFields,omitempty"` IsSubscribed bool `json:"isSubscribed,omitempty"`
SubscriptionDetailsId string `json:"subscriptionDetailsId,omitempty"` ReservationEndTime *time.Time `json:"reservationEndTime,omitempty"`
OrderReference string `json:"orderReference,omitempty"`
IsSubscribed bool `json:"isSubscribed,omitempty"`
ReservationEndTime *time.Time `json:"reservationEndTime,omitempty"`
// Extra holds arbitrary dynamic product data. Its keys are flattened onto
// the item object in JSON (see cart_item_json.go), so they are returned
// over the API alongside the typed fields. Typed fields win on key
// collisions.
Extra map[string]json.RawMessage `json:"-"`
} }
type CartNotification struct { type CartNotification struct {
@@ -97,31 +88,6 @@ type Marking struct {
Text string `json:"text"` Text string `json:"text"`
} }
// AppliedPromotion records a single promotion action's effect on the cart. It is
// rebuilt every time promotions are (re-)applied so the API result exposes a
// per-promotion breakdown rather than only the aggregate TotalDiscount.
//
// An entry is one of two kinds, both living in the same list:
// - applied (Pending=false): the action took effect now. Discount holds the
// amount taken off (nil for non-monetary effects such as free_shipping).
// - pending (Pending=true): the action has NOT qualified yet but is within
// reach. Progress carries effect-specific data describing what's left to
// unlock it — e.g. {"remaining": 120000, "threshold": 500000} for a
// cart-total nudge. Keeping it an open map lets new effect types surface
// their own progress fields without changing this struct. This powers
// "spend X more for ..." nudges generically.
type AppliedPromotion struct {
PromotionId string `json:"promotionId,omitempty"`
Name string `json:"name,omitempty"`
ActionId string `json:"actionId,omitempty"`
Type string `json:"type,omitempty"`
Label string `json:"label,omitempty"`
Discount *Price `json:"discount,omitempty"`
Pending bool `json:"pending,omitempty"`
Progress map[string]interface{} `json:"progress,omitempty"`
}
type CartGrain struct { type CartGrain struct {
mu sync.RWMutex mu sync.RWMutex
lastItemId uint32 lastItemId uint32
@@ -142,7 +108,6 @@ type CartGrain struct {
OrderReference string `json:"orderReference,omitempty"` OrderReference string `json:"orderReference,omitempty"`
Vouchers []*Voucher `json:"vouchers,omitempty"` Vouchers []*Voucher `json:"vouchers,omitempty"`
AppliedPromotions []AppliedPromotion `json:"appliedPromotions,omitempty"`
Notifications []CartNotification `json:"cartNotification,omitempty"` Notifications []CartNotification `json:"cartNotification,omitempty"`
SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"` SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"`
@@ -271,7 +236,6 @@ func (c *CartGrain) FindItemWithSku(sku string) (*CartItem, bool) {
func (c *CartGrain) UpdateTotals() { func (c *CartGrain) UpdateTotals() {
c.TotalPrice = NewPrice() c.TotalPrice = NewPrice()
c.TotalDiscount = NewPrice() c.TotalDiscount = NewPrice()
c.AppliedPromotions = nil
for _, item := range c.Items { for _, item := range c.Items {
rowTotal := MultiplyPrice(item.Price, int64(item.Quantity)) rowTotal := MultiplyPrice(item.Price, int64(item.Quantity))
+2 -3
View File
@@ -4,8 +4,8 @@ import (
"context" "context"
"time" "time"
"git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory" "github.com/matst80/go-redis-inventory/pkg/inventory"
) )
type CartMutationContext struct { type CartMutationContext struct {
@@ -79,7 +79,6 @@ func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegist
actor.NewMutation(SetUserId), actor.NewMutation(SetUserId),
actor.NewMutation(LineItemMarking), actor.NewMutation(LineItemMarking),
actor.NewMutation(RemoveLineItemMarking), actor.NewMutation(RemoveLineItemMarking),
actor.NewMutation(SetLineItemCustomFields),
actor.NewMutation(SubscriptionAdded), actor.NewMutation(SubscriptionAdded),
// actor.NewMutation(SubscriptionRemoved), // actor.NewMutation(SubscriptionRemoved),
) )
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/go-cart-actor/pkg/actor"
) )
// cart_id.go // cart_id.go
-77
View File
@@ -1,77 +0,0 @@
package cart
import (
"encoding/json"
"reflect"
"strings"
)
// cartItemAlias mirrors CartItem but has no custom (Un)MarshalJSON, so it is
// used to (de)serialize the typed fields without recursing.
type cartItemAlias CartItem
// knownItemKeys is the set of top-level JSON object keys owned by typed
// CartItem fields. Any other key in an item object is dynamic and kept in
// CartItem.Extra.
var knownItemKeys = buildKnownItemKeys()
func buildKnownItemKeys() map[string]struct{} {
keys := make(map[string]struct{})
t := reflect.TypeOf(CartItem{})
for i := 0; i < t.NumField(); i++ {
name, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ",")
if name == "" {
name = t.Field(i).Name // untagged fields serialize under their Go name
}
if name == "-" {
continue // e.g. Extra itself
}
keys[name] = struct{}{}
}
return keys
}
// MarshalJSON emits the typed fields and flattens Extra onto the same object.
// Typed fields take precedence on key collisions.
func (c CartItem) MarshalJSON() ([]byte, error) {
core, err := json.Marshal(cartItemAlias(c))
if err != nil || len(c.Extra) == 0 {
return core, err
}
merged := make(map[string]json.RawMessage, len(c.Extra)+8)
if err := json.Unmarshal(core, &merged); err != nil {
return nil, err
}
for k, v := range c.Extra {
if _, taken := merged[k]; taken {
continue
}
merged[k] = v
}
return json.Marshal(merged)
}
// UnmarshalJSON reads the typed fields and collects every other key into Extra.
func (c *CartItem) UnmarshalJSON(data []byte) error {
var alias cartItemAlias
if err := json.Unmarshal(data, &alias); err != nil {
return err
}
*c = CartItem(alias)
all := make(map[string]json.RawMessage)
if err := json.Unmarshal(data, &all); err != nil {
return err
}
for k := range all {
if _, known := knownItemKeys[k]; known {
delete(all, k)
}
}
if len(all) > 0 {
c.Extra = all
} else {
c.Extra = nil
}
return nil
}
-94
View File
@@ -1,94 +0,0 @@
package cart
import (
"encoding/json"
"testing"
)
func TestCartItem_FlattensDynamicKeys(t *testing.T) {
item := CartItem{
Id: 7,
Sku: "YD10050921H11406",
Quantity: 2,
Extra: map[string]json.RawMessage{
"glas": json.RawMessage(`"V"`),
"hangning": json.RawMessage(`"H"`),
"deliveryWeek": json.RawMessage(`10`),
},
}
b, err := json.Marshal(&item)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var flat map[string]json.RawMessage
if err := json.Unmarshal(b, &flat); err != nil {
t.Fatalf("unmarshal to map: %v", err)
}
// Typed field present at top level.
if _, ok := flat["sku"]; !ok {
t.Error("expected typed key \"sku\" at top level")
}
// Dynamic keys flattened to top level (not nested under \"extra\").
for _, k := range []string{"glas", "hangning", "deliveryWeek"} {
if _, ok := flat[k]; !ok {
t.Errorf("expected dynamic key %q flattened to top level", k)
}
}
if string(flat["glas"]) != `"V"` {
t.Errorf("glas = %s, want \"V\"", flat["glas"])
}
}
func TestCartItem_RoundTrip(t *testing.T) {
original := CartItem{
Id: 3,
Sku: "ABC",
Quantity: 1,
Extra: map[string]json.RawMessage{
"color": json.RawMessage(`"red"`),
"weight": json.RawMessage(`12.5`),
},
}
b, err := json.Marshal(&original)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got CartItem
if err := json.Unmarshal(b, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.Sku != original.Sku || got.Id != original.Id || got.Quantity != original.Quantity {
t.Errorf("typed fields not preserved: got %+v", got)
}
if len(got.Extra) != 2 {
t.Fatalf("Extra len = %d, want 2 (%v)", len(got.Extra), got.Extra)
}
if string(got.Extra["color"]) != `"red"` {
t.Errorf("color = %s, want \"red\"", got.Extra["color"])
}
// A typed key must never leak into Extra.
if _, leaked := got.Extra["sku"]; leaked {
t.Error("typed key \"sku\" leaked into Extra")
}
}
func TestCartItem_NoExtraIsCompact(t *testing.T) {
item := CartItem{Id: 1, Sku: "X", Quantity: 1}
b, err := json.Marshal(&item)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var flat map[string]json.RawMessage
if err := json.Unmarshal(b, &flat); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if _, ok := flat["sku"]; !ok {
t.Error("expected \"sku\" in output")
}
}
-44
View File
@@ -1,44 +0,0 @@
package mcp
import "encoding/json"
// JSON-RPC 2.0 envelope types (the wire format MCP rides on). Notifications are
// requests with no id and must not receive a response.
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
func (r *rpcRequest) isNotification() bool { return len(r.ID) == 0 }
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Result any `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
const (
codeParseError = -32700
codeInvalidRequest = -32600
codeMethodNotFound = -32601
codeInvalidParams = -32602
codeInternalError = -32603
)
func result(id json.RawMessage, v any) *rpcResponse {
return &rpcResponse{JSONRPC: "2.0", ID: id, Result: v}
}
func errorResponse(id json.RawMessage, code int, msg string) *rpcResponse {
return &rpcResponse{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}}
}
-159
View File
@@ -1,159 +0,0 @@
// Package mcp is the MCP edge for the cart: a Model Context Protocol server
// exposing the cart grain as tools so an agent can inspect and mutate carts
// (list items, change quantities, remove items, clear carts, apply vouchers,
// manage users, etc.).
//
// Transport is streamable HTTP (JSON-RPC 2.0 over POST). It is mounted by
// cmd/cart under /mcp on the same HTTP server as the cart API. Tools map
// 1:1 to cart grain read/apply operations; no restart is needed because every
// tool call goes through the shared grain pool.
package mcp
import (
"context"
"encoding/json"
"io"
"net/http"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"google.golang.org/protobuf/proto"
)
const (
serverName = "cart"
serverVersion = "0.1.0"
protocolVersion = "2025-06-18"
)
// Applier is the minimal grain-pool interface the cart MCP needs: read a
// cart's current state (Get) and apply a mutation (Apply). The
// SimpleGrainPool[cart.CartGrain] in cmd/cart satisfies it directly.
type Applier interface {
Get(ctx context.Context, id uint64) (*cart.CartGrain, error)
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[cart.CartGrain], error)
}
// Server is the MCP edge over the cart grain pool.
type Server struct {
applier Applier
tools []tool
}
// New builds an MCP server exposing the cart grain as tools.
func New(applier Applier) *Server {
s := &Server{applier: applier}
s.tools = s.buildTools()
return s
}
// Handler returns the streamable-HTTP handler to mount (e.g. at /mcp).
func (s *Server) Handler() http.Handler {
return http.HandlerFunc(s.serveHTTP)
}
func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
if err != nil {
writeRPC(w, errorResponse(nil, codeParseError, "read error"))
return
}
if isBatch(body) {
var reqs []rpcRequest
if err := json.Unmarshal(body, &reqs); err != nil {
writeRPC(w, errorResponse(nil, codeParseError, "invalid JSON"))
return
}
var out []*rpcResponse
for i := range reqs {
if resp := s.dispatch(&reqs[i]); resp != nil {
out = append(out, resp)
}
}
if len(out) == 0 {
w.WriteHeader(http.StatusAccepted)
return
}
writeRPC(w, out)
return
}
var req rpcRequest
if err := json.Unmarshal(body, &req); err != nil {
writeRPC(w, errorResponse(nil, codeParseError, "invalid JSON"))
return
}
resp := s.dispatch(&req)
if resp == nil {
w.WriteHeader(http.StatusAccepted)
return
}
writeRPC(w, resp)
}
func (s *Server) dispatch(req *rpcRequest) *rpcResponse {
if req.JSONRPC != "2.0" {
if req.isNotification() {
return nil
}
return errorResponse(req.ID, codeInvalidRequest, "jsonrpc must be \"2.0\"")
}
switch req.Method {
case "initialize":
return result(req.ID, s.initialize(req.Params))
case "ping":
return result(req.ID, struct{}{})
case "tools/list":
return result(req.ID, map[string]any{"tools": s.tools})
case "tools/call":
return s.callTool(req)
case "notifications/initialized", "notifications/cancelled":
return nil
default:
if req.isNotification() {
return nil
}
return errorResponse(req.ID, codeMethodNotFound, "unknown method: "+req.Method)
}
}
func (s *Server) initialize(params json.RawMessage) map[string]any {
pv := protocolVersion
if len(params) > 0 {
var p struct {
ProtocolVersion string `json:"protocolVersion"`
}
if json.Unmarshal(params, &p) == nil && p.ProtocolVersion != "" {
pv = p.ProtocolVersion
}
}
return map[string]any{
"protocolVersion": pv,
"capabilities": map[string]any{"tools": map[string]any{}},
"serverInfo": map[string]any{"name": serverName, "version": serverVersion},
}
}
func isBatch(body []byte) bool {
for _, b := range body {
switch b {
case ' ', '\t', '\r', '\n':
continue
case '[':
return true
default:
return false
}
}
return false
}
func writeRPC(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(v)
}
-421
View File
@@ -1,421 +0,0 @@
package mcp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"google.golang.org/protobuf/proto"
)
// testApplier implements Applier with an in-memory map of carts, using the real
// cart mutation registry so write tools exercise the actual mutation handlers.
type testApplier struct {
grains map[uint64]*cart.CartGrain
reg actor.MutationRegistry
}
func newTestApplier() *testApplier {
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
return &testApplier{
grains: make(map[uint64]*cart.CartGrain),
reg: reg,
}
}
func (a *testApplier) addCart(id uint64, items int) *cart.CartGrain {
g := cart.NewCartGrain(id, time.Now())
g.Currency = "SEK"
g.Language = "sv-se"
for i := 1; i <= items; i++ {
g.Items = append(g.Items, &cart.CartItem{
Id: uint32(i),
ItemId: uint32(1000 + i),
Sku: fmt.Sprintf("SKU-%03d", i),
Quantity: uint16(i),
Price: *cart.NewPriceFromIncVat(int64(i*10000), 25),
Tax: 2500,
Meta: &cart.ItemMeta{Name: fmt.Sprintf("Item %d", i)},
})
}
g.UpdateTotals()
a.grains[id] = g
return g
}
func (a *testApplier) Get(_ context.Context, id uint64) (*cart.CartGrain, error) {
g, ok := a.grains[id]
if !ok {
return nil, fmt.Errorf("cart %d not found", id)
}
return g, nil
}
func (a *testApplier) Apply(ctx context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[cart.CartGrain], error) {
g, ok := a.grains[id]
if !ok {
return nil, fmt.Errorf("cart %d not found", id)
}
results, err := a.reg.Apply(ctx, g, msgs...)
if err != nil {
return nil, err
}
// Re-read state after mutations.
state, err := g.GetCurrentState()
if err != nil {
return nil, err
}
return &actor.MutationResult[cart.CartGrain]{
Result: *state, //nolint:govet // test helper snapshot, same pattern as SimpleGrainPool
Mutations: results,
}, nil
}
// call issues a tools/call against the handler and returns the decoded text
// payload (the JSON the tool returned), failing on a tool/transport error.
func call(t *testing.T, h http.Handler, name string, args map[string]any) map[string]any {
t.Helper()
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": map[string]any{"name": name, "arguments": args},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body))
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s: status %d", name, rec.Code)
}
var resp struct {
Result struct {
IsError bool `json:"isError"`
Content []struct {
Text string `json:"text"`
} `json:"content"`
} `json:"result"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("%s: decode response: %v (%s)", name, err, rec.Body.String())
}
if resp.Error != nil {
t.Fatalf("%s: rpc error: %s", name, resp.Error.Message)
}
if resp.Result.IsError {
t.Fatalf("%s: tool error: %s", name, resp.Result.Content[0].Text)
}
var out map[string]any
if len(resp.Result.Content) > 0 {
_ = json.Unmarshal([]byte(resp.Result.Content[0].Text), &out)
}
return out
}
func TestCartMCPRoundTrip(t *testing.T) {
a := newTestApplier()
a.addCart(42, 3) // cart 42 with 3 items
h := New(a).Handler()
// get_cart
got := call(t, h, "get_cart", map[string]any{"cartId": "g"})
if got == nil {
t.Fatal("get_cart returned nil")
}
if id, _ := got["id"].(string); id != "g" {
t.Errorf("get_cart id = %v, want g", got["id"])
}
if curr, _ := got["currency"].(string); curr != "SEK" {
t.Errorf("get_cart currency = %q, want SEK", curr)
}
// get_cart_items
items := call(t, h, "get_cart_items", map[string]any{"cartId": "g"})
if c, _ := items["count"].(float64); c != 3 {
t.Errorf("get_cart_items count = %v, want 3", items["count"])
}
// get_cart_totals
totals := call(t, h, "get_cart_totals", map[string]any{"cartId": "g"})
if _, ok := totals["totalPrice"]; !ok {
t.Errorf("get_cart_totals missing totalPrice: %v", totals)
}
if _, ok := totals["totalDiscount"]; !ok {
t.Errorf("get_cart_totals missing totalDiscount: %v", totals)
}
// get_cart_vouchers (empty cart)
vouchers := call(t, h, "get_cart_vouchers", map[string]any{"cartId": "g"})
if c, _ := vouchers["count"].(float64); c != 0 {
t.Errorf("get_cart_vouchers count = %v, want 0", vouchers["count"])
}
// update_item_quantity: change item id=1 from qty 1 to qty 5
updated := call(t, h, "update_item_quantity", map[string]any{
"cartId": "g", "itemId": 1, "quantity": 5,
})
if updated == nil {
t.Fatal("update_item_quantity returned nil")
}
// Verify via get_cart_items
items2 := call(t, h, "get_cart_items", map[string]any{"cartId": "g"})
itemsRaw, _ := items2["items"].([]any)
if len(itemsRaw) != 3 {
t.Fatalf("after qty change: want 3 items, got %d", len(itemsRaw))
}
firstItem := itemsRaw[0].(map[string]any)
if q, _ := firstItem["qty"].(float64); q != 5 {
t.Errorf("item 1 qty = %v, want 5", q)
}
// remove_cart_item: remove item id=2
removed := call(t, h, "remove_cart_item", map[string]any{
"cartId": "g", "itemId": 2,
})
if removed == nil {
t.Fatal("remove_cart_item returned nil")
}
items3 := call(t, h, "get_cart_items", map[string]any{"cartId": "g"})
itemsRaw3, _ := items3["items"].([]any)
if len(itemsRaw3) != 2 {
t.Fatalf("after remove: want 2 items, got %d", len(itemsRaw3))
}
// apply_voucher
withVoucher := call(t, h, "apply_voucher", map[string]any{
"cartId": "g", "code": "SAVE10", "value": 10000,
})
if withVoucher == nil {
t.Fatal("apply_voucher returned nil")
}
vouchers2 := call(t, h, "get_cart_vouchers", map[string]any{"cartId": "g"})
if c, _ := vouchers2["count"].(float64); c != 1 {
t.Errorf("after voucher apply: count = %v, want 1", vouchers2["count"])
}
// remove_voucher: find the voucher id
voucherList, _ := vouchers2["vouchers"].([]any)
if len(voucherList) > 0 {
v := voucherList[0].(map[string]any)
vID := v["id"].(float64)
call(t, h, "remove_voucher", map[string]any{
"cartId": "g", "voucherId": int(vID),
})
vouchers3 := call(t, h, "get_cart_vouchers", map[string]any{"cartId": "g"})
if c, _ := vouchers3["count"].(float64); c != 0 {
t.Errorf("after remove voucher: count = %v, want 0", vouchers3["count"])
}
}
// set_cart_user
withUser := call(t, h, "set_cart_user", map[string]any{
"cartId": "g", "userId": "user-abc-123",
})
if withUser == nil {
t.Fatal("set_cart_user returned nil")
}
// clear_cart
cleared := call(t, h, "clear_cart", map[string]any{"cartId": "g"})
if cleared == nil {
t.Fatal("clear_cart returned nil")
}
items4 := call(t, h, "get_cart_items", map[string]any{"cartId": "g"})
if c, _ := items4["count"].(float64); c != 0 {
t.Errorf("after clear: count = %v, want 0", items4["count"])
}
}
func TestCartMCPErrors(t *testing.T) {
a := newTestApplier()
a.addCart(1, 1)
h := New(a).Handler()
// Invalid cart id -> isError result.
result := callWithRaw(t, h, "get_cart", map[string]any{"cartId": "!!!"})
if !result.IsError {
t.Fatal("expected isError for invalid cart id")
}
// Missing required cartId -> still hits decode (cartId absent is empty string)
result2 := callWithRaw(t, h, "get_cart", map[string]any{})
if !result2.IsError {
t.Fatal("expected isError for missing cartId")
}
// Non-existent cart -> isError result.
result3 := callWithRaw(t, h, "get_cart", map[string]any{"cartId": "zzzzzz"})
if !result3.IsError {
t.Fatal("expected isError for non-existent cart")
}
// Remove non-existent item -> isError
result4 := callWithRaw(t, h, "remove_cart_item", map[string]any{
"cartId": "1", "itemId": 999,
})
if !result4.IsError {
t.Fatal("expected isError for non-existent item")
}
// Unknown tool -> protocol error.
var resp struct {
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": map[string]any{"name": "nope", "arguments": map[string]any{}},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body))
h.ServeHTTP(rec, req)
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Error == nil {
t.Fatal("expected protocol error for unknown tool")
}
}
// callWithRaw returns the raw result (including isError) without failing.
type rawResult struct {
IsError bool
Content []struct {
Text string `json:"text"`
}
}
func callWithRaw(t *testing.T, h http.Handler, name string, args map[string]any) rawResult {
t.Helper()
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": map[string]any{"name": name, "arguments": args},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body))
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s: status %d", name, rec.Code)
}
var resp struct {
Result rawResult `json:"result"`
Error *struct{ Message string } `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("%s: decode: %v", name, err)
}
if resp.Error != nil {
t.Fatalf("%s: rpc error: %s", name, resp.Error.Message)
}
return resp.Result
}
func TestInitializeAndToolsList(t *testing.T) {
a := newTestApplier()
h := New(a).Handler()
// Initialize.
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": map[string]any{"protocolVersion": "2025-06-18"},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body))
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("initialize: status %d", rec.Code)
}
var initResp struct {
Result struct {
ProtocolVersion string `json:"protocolVersion"`
ServerInfo struct {
Name string `json:"name"`
} `json:"serverInfo"`
} `json:"result"`
}
json.Unmarshal(rec.Body.Bytes(), &initResp)
if initResp.Result.ProtocolVersion != "2025-06-18" || initResp.Result.ServerInfo.Name != "cart" {
t.Fatalf("unexpected initialize: %+v", initResp)
}
// tools/list.
body2, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
})
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body2))
h.ServeHTTP(rec2, req2)
var listResp struct {
Result struct {
Tools []struct {
Name string `json:"name"`
InputSchema json.RawMessage `json:"inputSchema"`
} `json:"tools"`
} `json:"result"`
}
json.Unmarshal(rec2.Body.Bytes(), &listResp)
want := map[string]bool{
"get_cart": false, "update_item_quantity": false,
"remove_cart_item": false, "clear_cart": false,
}
for _, tl := range listResp.Result.Tools {
if _, ok := want[tl.Name]; ok {
want[tl.Name] = true
}
if len(tl.InputSchema) == 0 {
t.Fatalf("tool %s has empty inputSchema", tl.Name)
}
}
for name, found := range want {
if !found {
t.Fatalf("expected tool %q in tools/list", name)
}
}
}
func TestCartMCPCartIdRoundTrip(t *testing.T) {
// Verify that we can round-trip a base62 cart id through tools.
a := newTestApplier()
// Use a larger cart id to test base62 encoding.
g := cart.NewCartGrain(12345, time.Now())
g.Currency = "EUR"
a.grains[12345] = g
h := New(a).Handler()
got := call(t, h, "get_cart", map[string]any{"cartId": g.Id.String()})
if curr, _ := got["currency"].(string); curr != "EUR" {
t.Errorf("currency = %q, want EUR", curr)
}
}
func TestGetCartPromotions(t *testing.T) {
a := newTestApplier()
g := a.addCart(99, 2)
// Add a synthetic applied promotion.
g.AppliedPromotions = []cart.AppliedPromotion{
{PromotionId: "volymrabatt", Name: "Volymrabatt", Type: "tiered_discount", Discount: cart.NewPriceFromIncVat(10000, 25)},
}
h := New(a).Handler()
result := call(t, h, "get_cart_promotions", map[string]any{"cartId": g.Id.String()})
if result == nil {
t.Fatal("get_cart_promotions returned nil")
}
promos, _ := result["appliedPromotions"].([]any)
if len(promos) != 1 {
t.Fatalf("want 1 promotion, got %d", len(promos))
}
p := promos[0].(map[string]any)
if name, _ := p["name"].(string); name != "Volymrabatt" {
t.Errorf("promotion name = %q, want Volymrabatt", name)
}
}
-481
View File
@@ -1,481 +0,0 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"log"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
)
// tool is one MCP tool: a name, a description, a JSON-Schema for its arguments,
// and the handler that runs it.
type tool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"inputSchema"`
invoke func(args json.RawMessage) (any, error) `json:"-"`
}
func (s *Server) callTool(req *rpcRequest) *rpcResponse {
var p struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
if err := json.Unmarshal(req.Params, &p); err != nil {
return errorResponse(req.ID, codeInvalidParams, "invalid params")
}
// Extract UCP meta from arguments (UCP-Agent profile advertisement).
// Per the UCP spec, meta is a sibling key inside arguments:
// "arguments": { "meta": { "ucp-agent": { "profile": "..." } }, ... }
args := extractUCPAgentMeta(&p.Arguments)
var t *tool
for i := range s.tools {
if s.tools[i].Name == p.Name {
t = &s.tools[i]
break
}
}
if t == nil {
return errorResponse(req.ID, codeInvalidParams, "unknown tool: "+p.Name)
}
out, err := t.invoke(args)
if err != nil {
return result(req.ID, toolError(err))
}
return result(req.ID, toolText(out))
}
// extractUCPAgentMeta checks if the JSON arguments contain a "meta" key with
// a nested "ucp-agent.profile" field. If found, it logs the profile (for
// observability) and strips the "meta" key before returning the cleaned
// arguments to the tool handler, so the meta object does not leak into the
// tool's argument namespace.
func extractUCPAgentMeta(arguments *json.RawMessage) json.RawMessage {
if arguments == nil || len(*arguments) == 0 {
return *arguments
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(*arguments, &obj); err != nil {
return *arguments
}
metaRaw, hasMeta := obj["meta"]
if !hasMeta {
return *arguments
}
// Try to extract the profile for observability.
var meta struct {
UCPAgent *struct {
Profile string `json:"profile"`
} `json:"ucp-agent"`
}
if err := json.Unmarshal(metaRaw, &meta); err == nil && meta.UCPAgent != nil && meta.UCPAgent.Profile != "" {
log.Printf("ucp-agent: profile=%s", meta.UCPAgent.Profile)
}
// Strip meta from arguments before passing to the tool handler.
delete(obj, "meta")
cleaned, err := json.Marshal(obj)
if err != nil {
return *arguments
}
return cleaned
}
func (s *Server) buildTools() []tool {
return []tool{
{
Name: "get_cart",
Description: "Get the full state of a cart by its base62 cart id: items, totals, vouchers, promotions, user info, currency, language, checkout status, subscription details, and all applied/pending promotions with progress nudges.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
}, []string{"cartId"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
g, err := s.applier.Get(context.Background(), uint64(id))
if err != nil {
return nil, fmt.Errorf("get cart: %w", err)
}
return g, nil
},
},
{
Name: "get_cart_items",
Description: "List all items in a cart with their SKU, name, quantity, unit price, total price, stock, tax rate, markings, custom fields, and optional child/parent relationships.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
}, []string{"cartId"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
g, err := s.applier.Get(context.Background(), uint64(id))
if err != nil {
return nil, fmt.Errorf("get cart: %w", err)
}
return map[string]any{"items": g.Items, "count": len(g.Items)}, nil
},
},
{
Name: "get_cart_totals",
Description: "Get price breakdown for a cart: total incVat, total exVat, VAT breakdown by rate, total discount (vouchers + promotions), and per-item totals.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
}, []string{"cartId"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
g, err := s.applier.Get(context.Background(), uint64(id))
if err != nil {
return nil, fmt.Errorf("get cart: %w", err)
}
return map[string]any{
"totalPrice": g.TotalPrice,
"totalDiscount": g.TotalDiscount,
"currency": g.Currency,
"language": g.Language,
"vouchers": g.Vouchers,
}, nil
},
},
{
Name: "get_cart_promotions",
Description: "Get all applied and pending promotions on a cart, including discount amounts and progress nudges (e.g. 'spend 1200 SEK more for free shipping').",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
}, []string{"cartId"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
g, err := s.applier.Get(context.Background(), uint64(id))
if err != nil {
return nil, fmt.Errorf("get cart: %w", err)
}
return map[string]any{
"appliedPromotions": g.AppliedPromotions,
"totalDiscount": g.TotalDiscount,
}, nil
},
},
{
Name: "get_cart_vouchers",
Description: "List all vouchers applied to a cart with their codes, values, rules, and whether they are currently active.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
}, []string{"cartId"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
g, err := s.applier.Get(context.Background(), uint64(id))
if err != nil {
return nil, fmt.Errorf("get cart: %w", err)
}
return map[string]any{"vouchers": g.Vouchers, "count": len(g.Vouchers)}, nil
},
},
{
Name: "update_item_quantity",
Description: "Change the quantity of a line item in a cart. Use quantity=0 to remove the item. Returns the updated cart state.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
"itemId": integer("the line item id (numeric, e.g. 1, 2, 3)"),
"quantity": integer("the new quantity (0 removes the item)"),
}, []string{"cartId", "itemId", "quantity"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
ItemID int `json:"itemId"`
Quantity int32 `json:"quantity"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.ChangeQuantity{
Id: uint32(a.ItemID),
Quantity: a.Quantity,
})
if err != nil {
return nil, fmt.Errorf("change quantity: %w", err)
}
// Check for per-mutation errors.
for _, m := range res.Mutations {
if m.Error != nil {
return nil, m.Error
}
}
return map[string]any{"cartId": a.CartID, "cart": &res.Result}, nil
},
},
{
Name: "remove_cart_item",
Description: "Remove a line item (and its children) from a cart by item id. Returns the updated cart state.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
"itemId": integer("the line item id to remove (numeric, e.g. 1, 2, 3)"),
}, []string{"cartId", "itemId"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
ItemID int `json:"itemId"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.RemoveItem{Id: uint32(a.ItemID)})
if err != nil {
return nil, fmt.Errorf("remove item: %w", err)
}
for _, m := range res.Mutations {
if m.Error != nil {
return nil, m.Error
}
}
return map[string]any{"cartId": a.CartID, "cart": &res.Result}, nil
},
},
{
Name: "clear_cart",
Description: "Remove all items and vouchers from a cart, resetting it to an empty state. The cart id, user id, and currency are preserved. Returns the updated (empty) cart.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
}, []string{"cartId"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.ClearCartRequest{})
if err != nil {
return nil, fmt.Errorf("clear cart: %w", err)
}
for _, m := range res.Mutations {
if m.Error != nil {
return nil, m.Error
}
}
return map[string]any{"cartId": a.CartID, "cart": &res.Result}, nil
},
},
{
Name: "apply_voucher",
Description: "Apply a voucher code to a cart. The voucher value is in öre (e.g. 10000 = 100 kr). Returns the updated cart with the voucher applied.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
"code": str("the voucher code"),
"value": integer("the voucher value in öre (e.g. 10000 = 100 kr)"),
"description": str("optional description of the voucher"),
"rules": stringArray("optional list of rule expressions for when the voucher applies"),
}, []string{"cartId", "code", "value"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
Code string `json:"code"`
Value int64 `json:"value"`
Description string `json:"description"`
Rules []string `json:"rules"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.AddVoucher{
Code: a.Code,
Value: a.Value,
Description: a.Description,
VoucherRules: a.Rules,
})
if err != nil {
return nil, fmt.Errorf("apply voucher: %w", err)
}
for _, m := range res.Mutations {
if m.Error != nil {
return nil, m.Error
}
}
return map[string]any{"cartId": a.CartID, "cart": &res.Result}, nil
},
},
{
Name: "remove_voucher",
Description: "Remove a voucher from a cart by its voucher id. Returns the updated cart.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
"voucherId": integer("the voucher id to remove (numeric)"),
}, []string{"cartId", "voucherId"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
VoucherID int `json:"voucherId"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.RemoveVoucher{Id: uint32(a.VoucherID)})
if err != nil {
return nil, fmt.Errorf("remove voucher: %w", err)
}
for _, m := range res.Mutations {
if m.Error != nil {
return nil, m.Error
}
}
return map[string]any{"cartId": a.CartID, "cart": &res.Result}, nil
},
},
{
Name: "set_cart_user",
Description: "Set or update the user ID on a cart, linking it to a customer account. Returns the updated cart.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
"userId": str("the user/customer id"),
}, []string{"cartId", "userId"}),
invoke: func(args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
UserID string `json:"userId"`
}
if err := decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.SetUserId{UserId: a.UserID})
if err != nil {
return nil, fmt.Errorf("set user: %w", err)
}
for _, m := range res.Mutations {
if m.Error != nil {
return nil, m.Error
}
}
return map[string]any{"cartId": a.CartID, "cart": &res.Result}, nil
},
},
}
}
// ---- result + schema helpers -----------------------------------------------
func toolText(v any) map[string]any {
b, err := json.Marshal(v)
if err != nil {
return toolError(err)
}
return map[string]any{
"content": []map[string]any{{"type": "text", "text": string(b)}},
}
}
func toolError(err error) map[string]any {
return map[string]any{
"isError": true,
"content": []map[string]any{{"type": "text", "text": err.Error()}},
}
}
// decode unmarshals tool arguments, tolerating empty/absent arguments.
func decode(args json.RawMessage, v any) error {
if len(args) == 0 || string(args) == "null" {
return nil
}
if err := json.Unmarshal(args, v); err != nil {
return fmt.Errorf("invalid arguments: %w", err)
}
return nil
}
type props map[string]json.RawMessage
func object(p props, required []string) json.RawMessage {
m := map[string]any{
"type": "object",
"properties": p,
}
if len(required) > 0 {
m["required"] = required
}
b, _ := json.Marshal(m)
return b
}
func str(desc string) json.RawMessage { return scalar("string", desc) }
func integer(d string) json.RawMessage { return scalar("integer", d) }
func obj(desc string) json.RawMessage { return scalar("object", desc) }
func scalar(typ, desc string) json.RawMessage {
b, _ := json.Marshal(map[string]any{"type": typ, "description": desc})
return b
}
func stringArray(desc string) json.RawMessage {
b, _ := json.Marshal(map[string]any{
"type": "array", "description": desc, "items": map[string]string{"type": "string"},
})
return b
}
+4 -34
View File
@@ -2,30 +2,15 @@ package cart
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"log" "log"
"time" "time"
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart" cart_messages "git.k6n.net/go-cart-actor/proto/cart"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
) )
// decodeExtra unpacks the dynamic product data carried as raw JSON. Invalid
// payloads are logged and dropped rather than failing the mutation.
func decodeExtra(b []byte) map[string]json.RawMessage {
if len(b) == 0 {
return nil
}
m := map[string]json.RawMessage{}
if err := json.Unmarshal(b, &m); err != nil {
log.Printf("AddItem: invalid extra_json: %v", err)
return nil
}
return m
}
// mutation_add_item.go // mutation_add_item.go
// //
// Registers the AddItem cart mutation in the generic mutation registry. // Registers the AddItem cart mutation in the generic mutation registry.
@@ -33,13 +18,10 @@ func decodeExtra(b []byte) map[string]json.RawMessage {
// //
// Behavior: // Behavior:
// - Validates quantity > 0 // - Validates quantity > 0
// - If an item with the same item id (ItemId) exists -> increases quantity // - If an item with same SKU exists -> increases quantity
// - Else creates a new CartItem with computed tax amounts // - Else creates a new CartItem with computed tax amounts
// - Totals recalculated automatically via WithTotals() // - Totals recalculated automatically via WithTotals()
// //
// Item identity is the catalog item id (ItemId), not the SKU: the product
// service is looked up by id and the returned SKU is reference-only.
//
// NOTE: Any future field additions in messages.AddItem that affect pricing / tax // NOTE: Any future field additions in messages.AddItem that affect pricing / tax
// must keep this handler in sync. // must keep this handler in sync.
var ErrPaymentInProgress = errors.New("payment in progress") var ErrPaymentInProgress = errors.New("payment in progress")
@@ -53,10 +35,9 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity) return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity)
} }
// Merge with any existing item having the same item id and matching StoreId // Merge with any existing item having same SKU and matching StoreId (including both nil).
// (including both nil). Identity is the id; SKU is reference-only.
for _, existing := range g.Items { for _, existing := range g.Items {
if existing.ItemId != m.ItemId { if existing.Sku != m.Sku {
continue continue
} }
sameStore := (existing.StoreId == nil && m.StoreId == nil) || sameStore := (existing.StoreId == nil && m.StoreId == nil) ||
@@ -80,14 +61,6 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
if existing.StoreId == nil && m.StoreId != nil { if existing.StoreId == nil && m.StoreId != nil {
existing.StoreId = m.StoreId existing.StoreId = m.StoreId
} }
// Refresh dynamic product data with the latest payload.
if extra := decodeExtra(m.ExtraJson); extra != nil {
existing.Extra = extra
}
// Replace custom fields when provided on the re-add.
if len(m.CustomFields) > 0 {
existing.CustomFields = m.CustomFields
}
return nil return nil
} }
@@ -141,9 +114,6 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
ArticleType: m.ArticleType, ArticleType: m.ArticleType,
StoreId: m.StoreId, StoreId: m.StoreId,
Extra: decodeExtra(m.ExtraJson),
CustomFields: m.CustomFields,
} }
if needsReservation && c.UseReservations(cartItem) { if needsReservation && c.UseReservations(cartItem) {
-40
View File
@@ -1,40 +0,0 @@
package cart
import (
"context"
"testing"
"time"
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
)
// Item identity is the catalog item id, not the (reference-only) SKU: two adds
// with the same ItemId but different SKU strings must merge into one line.
func TestAddItem_MergesByItemIdNotSku(t *testing.T) {
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
g := NewCartGrain(1, time.Now())
ctx := context.Background()
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 144047, Sku: "A0162590", Quantity: 1, Price: 1000}); err != nil {
t.Fatalf("first add: %v", err)
}
// Same id, different (reference) sku string -> should merge, not create a line.
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 144047, Sku: "STALE-REF", Quantity: 2, Price: 1000}); err != nil {
t.Fatalf("second add: %v", err)
}
if len(g.Items) != 1 {
t.Fatalf("items = %d, want 1 (merged by id)", len(g.Items))
}
if g.Items[0].Quantity != 3 {
t.Errorf("quantity = %d, want 3", g.Items[0].Quantity)
}
// A different id is a distinct line.
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 170852, Sku: "A0190103", Quantity: 1, Price: 500}); err != nil {
t.Fatalf("third add: %v", err)
}
if len(g.Items) != 2 {
t.Fatalf("items = %d, want 2", len(g.Items))
}
}
+2 -2
View File
@@ -3,8 +3,8 @@ package cart
import ( import (
"slices" "slices"
"git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/go-cart-actor/pkg/actor"
messages "git.k6n.net/mats/go-cart-actor/proto/cart" messages "git.k6n.net/go-cart-actor/proto/cart"
) )
func RemoveVoucher(g *CartGrain, m *messages.RemoveVoucher) error { func RemoveVoucher(g *CartGrain, m *messages.RemoveVoucher) error {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"log" "log"
"time" "time"
messages "git.k6n.net/mats/go-cart-actor/proto/cart" messages "git.k6n.net/go-cart-actor/proto/cart"
) )
// mutation_change_quantity.go // mutation_change_quantity.go
+1 -1
View File
@@ -3,7 +3,7 @@ package cart
import ( import (
"fmt" "fmt"
messages "git.k6n.net/mats/go-cart-actor/proto/cart" messages "git.k6n.net/go-cart-actor/proto/cart"
) )
func ClearCart(g *CartGrain, m *messages.ClearCartRequest) error { func ClearCart(g *CartGrain, m *messages.ClearCartRequest) error {
-65
View File
@@ -1,65 +0,0 @@
package cart
import (
"context"
"testing"
"time"
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
)
func TestAddItem_StoresCustomFields(t *testing.T) {
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
g := NewCartGrain(1, time.Now())
mustApply(t, reg, g, &cart_messages.AddItem{
ItemId: 100, Sku: "P", Quantity: 1, Price: 1000,
CustomFields: map[string]string{"engraving": "Happy Birthday", "color": "blue"},
})
if len(g.Items) != 1 {
t.Fatalf("items = %d, want 1", len(g.Items))
}
cf := g.Items[0].CustomFields
if cf["engraving"] != "Happy Birthday" || cf["color"] != "blue" {
t.Fatalf("custom fields = %v, want engraving+color", cf)
}
}
func TestSetLineItemCustomFields_Merges(t *testing.T) {
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
g := NewCartGrain(1, time.Now())
mustApply(t, reg, g, &cart_messages.AddItem{
ItemId: 100, Sku: "P", Quantity: 1, Price: 1000,
CustomFields: map[string]string{"engraving": "v1"},
})
line := g.Items[0].Id
// Upsert: overwrite "engraving", add "note", leave others alone.
mustApply(t, reg, g, &cart_messages.SetLineItemCustomFields{
Id: line,
CustomFields: map[string]string{"engraving": "v2", "note": "gift wrap"},
})
cf := g.Items[0].CustomFields
if cf["engraving"] != "v2" {
t.Errorf("engraving = %q, want v2", cf["engraving"])
}
if cf["note"] != "gift wrap" {
t.Errorf("note = %q, want 'gift wrap'", cf["note"])
}
}
func TestSetLineItemCustomFields_UnknownItem(t *testing.T) {
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
g := NewCartGrain(1, time.Now())
// The handler error is reported per-mutation in the ApplyResult (the
// registry only returns a top-level error for unregistered mutations).
results, _ := reg.Apply(context.Background(), g, &cart_messages.SetLineItemCustomFields{
Id: 999, CustomFields: map[string]string{"x": "y"},
})
if len(results) != 1 || results[0].Error == nil {
t.Errorf("expected per-mutation error for unknown item id, got %+v", results)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ package cart
import ( import (
"fmt" "fmt"
messages "git.k6n.net/mats/go-cart-actor/proto/cart" messages "git.k6n.net/go-cart-actor/proto/cart"
) )
func LineItemMarking(grain *CartGrain, req *messages.LineItemMarking) error { func LineItemMarking(grain *CartGrain, req *messages.LineItemMarking) error {
+15 -37
View File
@@ -6,7 +6,7 @@ import (
"log" "log"
"time" "time"
messages "git.k6n.net/mats/go-cart-actor/proto/cart" messages "git.k6n.net/go-cart-actor/proto/cart"
) )
// mutation_remove_item.go // mutation_remove_item.go
@@ -15,17 +15,15 @@ import (
// //
// Behavior: // Behavior:
// - Removes the cart line whose local cart line Id == payload.Id // - Removes the cart line whose local cart line Id == payload.Id
// - Cascades: also removes any line whose ParentId points at a removed line
// (transitively), so removing a parent removes its child sub-articles
// - If no such line exists returns an error // - If no such line exists returns an error
// - Releases reservations for every removed line and recalculates totals // - Recalculates cart totals (WithTotals)
// //
// Notes: // Notes:
// - This removes only the line items; any deliveries referencing a removed // - This removes only the line item; any deliveries referencing the removed
// item are NOT automatically adjusted (mirrors prior logic). If future // item are NOT automatically adjusted (mirrors prior logic). If future
// semantics require pruning delivery.item_ids you can extend this handler. // semantics require pruning delivery.item_ids you can extend this handler.
// - If multiple lines somehow shared the same Id (should not happen), all // - If multiple lines somehow shared the same Id (should not happen), only
// matches are removed—data integrity relies on unique line Ids. // the first match would be removed—data integrity relies on unique line Ids.
func (c *CartMutationContext) RemoveItem(g *CartGrain, m *messages.RemoveItem) error { func (c *CartMutationContext) RemoveItem(g *CartGrain, m *messages.RemoveItem) error {
if m == nil { if m == nil {
@@ -34,46 +32,26 @@ func (c *CartMutationContext) RemoveItem(g *CartGrain, m *messages.RemoveItem) e
targetID := uint32(m.Id) targetID := uint32(m.Id)
found := false index := -1
for _, it := range g.Items { for i, it := range g.Items {
if it.Id == targetID { if it.Id == targetID {
found = true index = i
break break
} }
} }
if !found { if index == -1 {
return fmt.Errorf("RemoveItem: item id %d not found", m.Id) return fmt.Errorf("RemoveItem: item id %d not found", m.Id)
} }
// Collect the target and, transitively, any children pointing at a removed item := g.Items[index]
// line. Loops until no further descendants are found (handles nesting). if item.ReservationEndTime != nil && item.ReservationEndTime.After(time.Now()) {
remove := map[uint32]bool{targetID: true} err := c.ReleaseItem(context.Background(), g.Id, item.Sku, item.StoreId)
for { if err != nil {
grew := false log.Printf("unable to release item reservation")
for _, it := range g.Items {
if it.ParentId != nil && remove[*it.ParentId] && !remove[it.Id] {
remove[it.Id] = true
grew = true
}
}
if !grew {
break
} }
} }
kept := g.Items[:0] g.Items = append(g.Items[:index], g.Items[index+1:]...)
for _, it := range g.Items {
if remove[it.Id] {
if it.ReservationEndTime != nil && it.ReservationEndTime.After(time.Now()) {
if err := c.ReleaseItem(context.Background(), g.Id, it.Sku, it.StoreId); err != nil {
log.Printf("unable to release reservation for item %d: %v", it.Id, err)
}
}
continue
}
kept = append(kept, it)
}
g.Items = kept
g.UpdateTotals() g.UpdateTotals()
return nil return nil
} }
-70
View File
@@ -1,70 +0,0 @@
package cart
import (
"context"
"testing"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
"google.golang.org/protobuf/proto"
)
// Removing a parent line cascades to its child sub-articles, while unrelated
// lines are left untouched.
func TestRemoveItem_CascadesToChildren(t *testing.T) {
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
g := NewCartGrain(1, time.Now())
ctx := context.Background()
// Parent (line 1) + two children (lines 2,3) + an unrelated item (line 4).
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000})
parentLine := g.Items[0].Id
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine})
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 102, Sku: "C2", Quantity: 1, Price: 200, ParentId: &parentLine})
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 200, Sku: "OTHER", Quantity: 1, Price: 300})
if len(g.Items) != 4 {
t.Fatalf("setup: items = %d, want 4", len(g.Items))
}
if _, err := reg.Apply(ctx, g, &cart_messages.RemoveItem{Id: parentLine}); err != nil {
t.Fatalf("remove parent: %v", err)
}
if len(g.Items) != 1 {
t.Fatalf("after remove: items = %d, want 1 (only the unrelated line)", len(g.Items))
}
if g.Items[0].Sku != "OTHER" {
t.Errorf("remaining item sku = %q, want OTHER", g.Items[0].Sku)
}
}
// Removing a child leaves the parent and siblings intact.
func TestRemoveItem_ChildDoesNotRemoveParent(t *testing.T) {
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
g := NewCartGrain(1, time.Now())
ctx := context.Background()
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000})
parentLine := g.Items[0].Id
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine})
childLine := g.Items[1].Id
if _, err := reg.Apply(ctx, g, &cart_messages.RemoveItem{Id: childLine}); err != nil {
t.Fatalf("remove child: %v", err)
}
if len(g.Items) != 1 {
t.Fatalf("items = %d, want 1 (parent remains)", len(g.Items))
}
if g.Items[0].Id != parentLine {
t.Errorf("remaining line = %d, want parent %d", g.Items[0].Id, parentLine)
}
}
func mustApply(t *testing.T, reg actor.MutationRegistry, g *CartGrain, m proto.Message) {
t.Helper()
if _, err := reg.Apply(context.Background(), g, m); err != nil {
t.Fatalf("apply %T: %v", m, err)
}
}
@@ -3,7 +3,7 @@ package cart
import ( import (
"fmt" "fmt"
messages "git.k6n.net/mats/go-cart-actor/proto/cart" messages "git.k6n.net/go-cart-actor/proto/cart"
) )
func RemoveLineItemMarking(grain *CartGrain, req *messages.RemoveLineItemMarking) error { func RemoveLineItemMarking(grain *CartGrain, req *messages.RemoveLineItemMarking) error {
-31
View File
@@ -1,31 +0,0 @@
package cart
import (
"fmt"
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
)
// SetLineItemCustomFields sets/merges user-supplied custom input fields on an
// existing cart line. It is the dict equivalent of LineItemMarking: keys in the
// request are upserted; existing keys not present in the request are left
// untouched. (Send an empty value to clear a single field at the API layer if
// desired.)
func SetLineItemCustomFields(grain *CartGrain, req *messages.SetLineItemCustomFields) error {
for _, item := range grain.Items {
if item.Id != req.Id {
continue
}
if len(req.CustomFields) == 0 {
return nil
}
if item.CustomFields == nil {
item.CustomFields = make(map[string]string, len(req.CustomFields))
}
for k, v := range req.CustomFields {
item.CustomFields[k] = v
}
return nil
}
return fmt.Errorf("item with ID %d not found", req.Id)
}
+1 -1
View File
@@ -3,7 +3,7 @@ package cart
import ( import (
"errors" "errors"
messages "git.k6n.net/mats/go-cart-actor/proto/cart" messages "git.k6n.net/go-cart-actor/proto/cart"
) )
func SetUserId(grain *CartGrain, req *messages.SetUserId) error { func SetUserId(grain *CartGrain, req *messages.SetUserId) error {
+1 -1
View File
@@ -3,7 +3,7 @@ package cart
import ( import (
"fmt" "fmt"
messages "git.k6n.net/mats/go-cart-actor/proto/cart" messages "git.k6n.net/go-cart-actor/proto/cart"
) )
func SubscriptionAdded(grain *CartGrain, req *messages.SubscriptionAdded) error { func SubscriptionAdded(grain *CartGrain, req *messages.SubscriptionAdded) error {
@@ -4,7 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
messages "git.k6n.net/mats/go-cart-actor/proto/cart" messages "git.k6n.net/go-cart-actor/proto/cart"
) )
func UpsertSubscriptionDetails(g *CartGrain, m *messages.UpsertSubscriptionDetails) error { func UpsertSubscriptionDetails(g *CartGrain, m *messages.UpsertSubscriptionDetails) error {
+22 -25
View File
@@ -5,7 +5,7 @@ import (
"sync" "sync"
"time" "time"
"git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/go-cart-actor/pkg/cart"
) )
// CheckoutId is the same as CartId for simplicity // CheckoutId is the same as CartId for simplicity
@@ -75,10 +75,9 @@ func (p *Payment) IsSettled() bool {
} }
type ContactDetails struct { type ContactDetails struct {
Email *string `json:"email,omitempty"` Email *string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"` Phone *string `json:"phone,omitempty"`
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
PostalCode *string `json:"zip,omitempty"`
} }
type ConfirmationStatus struct { type ConfirmationStatus struct {
@@ -88,26 +87,24 @@ type ConfirmationStatus struct {
} }
type CheckoutGrain struct { type CheckoutGrain struct {
mu sync.RWMutex mu sync.RWMutex
lastDeliveryId uint32 lastDeliveryId uint32
lastGiftcardId uint32 lastGiftcardId uint32
lastAccess time.Time lastAccess time.Time
lastChange time.Time lastChange time.Time
Version uint32 `json:"version"` Version uint32 `json:"version"`
Id CheckoutId `json:"id"` Id CheckoutId `json:"id"`
CartId cart.CartId `json:"cartId"` CartId cart.CartId `json:"cartId"`
CartVersion uint64 `json:"cartVersion"` CartVersion uint64 `json:"cartVersion"`
CartState *cart.CartGrain `json:"cartState"` // snapshot of items CartState *cart.CartGrain `json:"cartState"` // snapshot of items
CartTotalPrice *cart.Price `json:"cartTotalPrice"` CartTotalPrice *cart.Price `json:"cartTotalPrice"`
OrderId *string `json:"orderId"` OrderId *string `json:"orderId"`
Deliveries []*CheckoutDelivery `json:"deliveries,omitempty"` Deliveries []*CheckoutDelivery `json:"deliveries,omitempty"`
PaymentInProgress uint16 `json:"paymentInProgress"` PaymentInProgress uint16 `json:"paymentInProgress"`
AmountInCentsRemaining int64 `json:"amountRemaining"` InventoryReserved bool `json:"inventoryReserved"`
AmountInCentsStarted int64 `json:"amountActive"` Confirmation *ConfirmationStatus `json:"confirmationViewed,omitempty"`
InventoryReserved bool `json:"inventoryReserved"` Payments []*Payment `json:"payments,omitempty"`
Confirmation *ConfirmationStatus `json:"confirmationViewed,omitempty"` ContactDetails *ContactDetails `json:"contactDetails,omitempty"`
Payments []*Payment `json:"payments,omitempty"`
ContactDetails *ContactDetails `json:"contactDetails,omitempty"`
} }
func NewCheckoutGrain(id uint64, cartId cart.CartId, cartVersion uint64, ts time.Time, cartState *cart.CartGrain) *CheckoutGrain { func NewCheckoutGrain(id uint64, cartId cart.CartId, cartVersion uint64, ts time.Time, cartState *cart.CartGrain) *CheckoutGrain {
+1 -2
View File
@@ -1,7 +1,7 @@
package checkout package checkout
import ( import (
"git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/go-cart-actor/pkg/actor"
) )
type CheckoutMutationContext struct { type CheckoutMutationContext struct {
@@ -27,7 +27,6 @@ func NewCheckoutMutationRegistry(ctx *CheckoutMutationContext) actor.MutationReg
actor.NewMutation(HandleSetPickupPoint), actor.NewMutation(HandleSetPickupPoint),
actor.NewMutation(HandleRemoveDelivery), actor.NewMutation(HandleRemoveDelivery),
actor.NewMutation(HandleContactDetailsUpdated), actor.NewMutation(HandleContactDetailsUpdated),
actor.NewMutation(HandlePaymentCancelled),
) )
return reg return reg
} }
-30
View File
@@ -1,30 +0,0 @@
package checkout
import (
"errors"
"slices"
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
)
func HandlePaymentCancelled(g *CheckoutGrain, m *messages.CancelPayment) error {
payment, found := g.FindPayment(m.PaymentId)
if !found {
return ErrPaymentNotFound
}
if payment.CompletedAt != nil {
return errors.New("payment already completed")
}
g.PaymentInProgress--
g.AmountInCentsStarted -= payment.Amount
g.Payments = removePayment(g.Payments, payment.PaymentId)
return nil
}
func removePayment(payment []*Payment, s string) []*Payment {
return slices.DeleteFunc(payment, func(p *Payment) bool {
return p.PaymentId == s
})
}
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"fmt" "fmt"
"time" "time"
messages "git.k6n.net/mats/go-cart-actor/proto/checkout" messages "git.k6n.net/go-cart-actor/proto/checkout"
) )
func HandleConfirmationViewed(g *CheckoutGrain, m *messages.ConfirmationViewed) error { func HandleConfirmationViewed(g *CheckoutGrain, m *messages.ConfirmationViewed) error {

Some files were not shown because too many files have changed in this diff Show More