Compare commits
289
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3df95eac90 | ||
|
|
0252893de5 | ||
|
|
c0c5a8bc0f | ||
|
|
10812d4203 | ||
|
|
414395bc0d | ||
|
|
86797e520e | ||
|
|
faa5330b81 | ||
|
|
87ba9c41b9 | ||
|
|
94b1f9c4ec | ||
|
|
e1eb342edf | ||
|
|
9aa587b9cf | ||
|
|
a1f0bad972 | ||
|
|
9d420c43fa | ||
|
|
716a66562e | ||
|
|
1a365de071 | ||
|
|
484f2364fb | ||
|
|
2443fd8b49 | ||
|
|
2545be4315 | ||
|
|
74dc252279 | ||
|
|
ad410d217c | ||
|
|
11be8aec56 | ||
|
|
d22f3432f6 | ||
|
|
ec49f96486 | ||
|
|
871edb6455 | ||
|
|
60722e3414 | ||
|
|
faec360789 | ||
|
|
7a11a7fde2 | ||
|
|
f55a3d56eb | ||
|
|
f133e2ac89 | ||
|
|
571f4f40cb | ||
|
|
81609d6c20 | ||
|
|
ea3eb2f0f3 | ||
|
|
b4ffb9da85 | ||
|
|
593d6cfe44 | ||
|
|
7a4ecb0bf7 | ||
|
|
2be13462c9 | ||
|
|
7964e8b849 | ||
|
|
d78685cd8f | ||
|
|
f67eeb3c49 | ||
|
|
6d5358b53b | ||
|
|
d0d604f5f0 | ||
|
|
2b8e692526 | ||
|
|
c842571dbc | ||
|
|
bd194f2bb8 | ||
|
|
94d478ee6a | ||
|
|
e9fb00d5c7 | ||
|
|
3d9d703a76 | ||
|
|
9a61b088c9 | ||
|
|
3becd1c80c | ||
|
|
dcc7cc2dd0 | ||
|
|
ce81e294f2 | ||
|
|
fccb0eed06 | ||
|
|
978ef3025d | ||
|
|
0ef29596c1 | ||
|
|
fbc773dbca | ||
|
|
5037c07a47 | ||
|
|
e9189364ab | ||
|
|
445858c1e3 | ||
|
|
82b64ee8d4 | ||
|
|
7606c1f9df | ||
|
|
0e1f9b0d3c | ||
|
|
95d8e0ffb5 | ||
|
|
c61fe1bb7b | ||
|
|
3af837d824 | ||
|
|
401286c56a | ||
|
|
bb4ed5eb23 | ||
|
|
f828f4809b | ||
|
|
825630a91a | ||
|
|
78bc05025d | ||
|
|
5ad7131df2 | ||
|
|
6321ad76a1 | ||
|
|
94a04a154c | ||
|
|
86fdfc8532 | ||
|
|
c57643ab63 | ||
|
|
9fde6d6bd7 | ||
|
|
1cca11de35 | ||
|
|
1b4e769573 | ||
|
|
8f2a354f9d | ||
|
|
4940b61ee0 | ||
|
|
ad24d503ba | ||
|
|
ca7d23f122 | ||
|
|
c7049793e2 | ||
|
|
dbdd759e16 | ||
|
|
ee5f54f0dd | ||
|
|
ebd1508294 | ||
|
|
d8791788fc | ||
|
|
ee1b96fece | ||
|
|
060b3dfbf0 | ||
|
|
c227870f13 | ||
|
|
d23bfe62a1 | ||
|
|
accdc41064 | ||
|
|
e419cde351 | ||
|
|
f0c487b5e1 | ||
|
|
84268eede4 | ||
|
|
aaa730e833 | ||
|
|
c11161b0ca | ||
|
|
cdb678f018 | ||
|
|
c9695d338d | ||
|
|
e1072c5ba1 | ||
|
|
aea2adcf92 | ||
|
|
90a525bd98 | ||
|
|
330093bdec | ||
|
|
47e69f18a5 | ||
|
|
2c0f6c160a | ||
|
|
23160479b3 | ||
|
|
d8db3c9289 | ||
|
|
43b7e749c4 | ||
|
|
c3d8773c2e | ||
|
|
c2873ebfc0 | ||
|
|
db61413988 | ||
|
|
65dc8edf48 | ||
|
|
fd29300314 | ||
|
|
aea168160e | ||
|
|
c2a137d8d4 | ||
|
|
51d7de03d6 | ||
|
|
3912135b35 | ||
|
|
afa79e7b56 | ||
|
|
a17d5fc570 | ||
|
|
fac94acfe1 | ||
|
|
beb03a7cd7 | ||
|
|
87625ffd5d | ||
|
|
9db4abdb05 | ||
|
|
a6f6d3c190 | ||
|
|
400079ec98 | ||
|
|
95426acd4a | ||
|
|
7e2726244f | ||
|
|
59e01e763f | ||
|
|
d5d2b3e711 | ||
|
|
f640cd7d2c | ||
|
|
82650bd3dd | ||
|
|
990708597b | ||
|
|
e7e572b0f7 | ||
|
|
b0907aee41 | ||
|
|
ff8a332e3b | ||
|
|
4ca6c4ca23 | ||
|
|
e74f5ae30e | ||
|
|
8d976dd250 | ||
|
|
fc678797ba | ||
|
|
374bd4272b | ||
|
|
ffd7ace839 | ||
|
|
c74968cfce | ||
|
|
f63ad5bc84 | ||
|
|
4dcc6fe1bd | ||
|
|
886e3ce48f | ||
|
|
896bf7672b | ||
|
|
c859796b1e | ||
|
|
a01feb615b | ||
|
|
f475b56659 | ||
|
|
430da09264 | ||
|
|
729c67f992 | ||
|
|
60cd6cfd51 | ||
|
|
1c8e9cc974 | ||
|
|
874963812f | ||
|
|
67f63244bb | ||
|
|
270539a2fe | ||
|
|
dd5de0b1a1 | ||
|
|
3e78dd8b00 | ||
|
|
13dcb1ec45 | ||
|
|
4fa78e786f | ||
|
|
c917bc62fb | ||
|
|
ac8b870eb5 | ||
|
|
cdece4e0d2 | ||
|
|
c9d9d62bfb | ||
|
|
e662192b56 | ||
|
|
a7aab5161b | ||
|
|
7fd6b22c6b | ||
|
|
ba0e820956 | ||
|
|
7a7c577374 | ||
|
|
da2b68b40e | ||
|
|
5816b6cd3b | ||
|
|
b135156837 | ||
|
|
c4c116fc43 | ||
|
|
0994c4f484 | ||
|
|
78436f39ae | ||
|
|
8905c5809c | ||
|
|
4eb621bae4 | ||
|
|
dd8e23aadd | ||
|
|
ea247e2600 | ||
|
|
718225164f | ||
|
|
cdfd263318 | ||
|
|
e25ca3304e | ||
|
|
af5d4cd325 | ||
|
|
cebd3ea80f | ||
|
|
a0f5ae0c61 | ||
|
|
67c9b2a7df | ||
|
|
e703b71d35 | ||
|
|
5bd0bdb44c | ||
|
|
0c9780f36c | ||
|
|
e0d8afae92 | ||
|
|
6d9f9bec11 | ||
|
|
a54435ebbb | ||
|
|
529e70fc68 | ||
|
|
3f9c790dc2 | ||
|
|
5223fef2fa | ||
|
|
7161c2a8b6 | ||
|
|
cc6d48c879 | ||
|
|
756a43b342 | ||
|
|
81246fe497 | ||
|
|
3fa0009b95 | ||
|
|
19fc5a9553 | ||
|
|
ab5d9cb2b7 | ||
|
|
5d4d917f6a | ||
|
|
af7ce20557 | ||
|
|
ed9a02227e | ||
|
|
34e0445857 | ||
|
|
61457bce6b | ||
|
|
8bf29020dd | ||
|
|
44d7c1faad | ||
|
|
caab742461 | ||
|
|
b272282b1f | ||
|
|
43fcf69139 | ||
|
|
7d9fd0ebb4 | ||
|
|
e662c7dafa | ||
|
|
d969da428f | ||
|
|
d0325e302e | ||
|
|
1aa12ff8d9 | ||
|
|
68eca49cd0 | ||
|
|
bb80c9ab13 | ||
|
|
dce00fb5e3 | ||
|
|
81e2fb5faa | ||
|
|
01d8d86c7c | ||
|
|
0c4d9e2245 | ||
|
|
8aab59a5f4 | ||
|
|
c1599af40b | ||
|
|
acf2a3a8c1 | ||
|
|
0a86bd380a | ||
|
|
42913a079f | ||
|
|
c71b668a87 | ||
|
|
89ee3e725f | ||
|
|
aef90e2bbb | ||
|
|
834bf9f7bc | ||
|
|
de77a3b707 | ||
|
|
162a2638fa | ||
|
|
5533d241e9 | ||
|
|
b36125d664 | ||
|
|
cd0ee22ddc | ||
|
|
00fcacf1be | ||
|
|
2378635790 | ||
|
|
da28e993cd | ||
|
|
00c2ff70da | ||
|
|
eb4061f1b8 | ||
|
|
e155736313 | ||
|
|
5e7591335c | ||
|
|
c68f726a96 | ||
|
|
82d564b136 | ||
|
|
eb1f7750df | ||
|
|
ce0bac477a | ||
|
|
7c0e3e84a2 | ||
|
|
c67ebd7a5f | ||
|
|
42e38504a3 | ||
|
|
b7ae36e53c | ||
|
|
d9fb49ec0b | ||
|
|
2202c149b8 | ||
|
|
e91433eda7 | ||
|
|
7eb000fd17 | ||
|
|
9ecd91c163 | ||
|
|
246a5ebd85 | ||
|
|
e1de5a00a0 | ||
|
|
99c9f611e7 | ||
|
|
df0cd58dcd | ||
|
|
86f97f2888 | ||
|
|
af3eb0d7bf | ||
|
|
a0c82dc351 | ||
|
|
0c127e9d38 | ||
|
|
662b381a34 | ||
|
|
e127251a60 | ||
|
|
b7f0990269 | ||
|
|
d58409e3fc | ||
|
|
2ce45656d9 | ||
|
|
dc352e3b74 | ||
|
|
915d845014 | ||
|
|
4a54661f24 | ||
|
|
918aa7d265 | ||
|
|
a1833d6685 | ||
|
|
614be25ae8 | ||
|
|
71fc23bf50 | ||
|
|
db918730d5 | ||
|
|
8ecad3060f | ||
|
|
c060680768 | ||
|
|
15089862d5 | ||
|
|
cdb0241c8a | ||
|
|
07a7ec5781 | ||
|
|
fa89670553 | ||
|
|
9ab0c08e79 | ||
|
|
8682daf481 | ||
|
|
8c2bcf5e75 | ||
|
|
47adb12112 | ||
|
|
4835041f14 | ||
|
|
104f9fbb4c |
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"description": "Project Overview",
|
||||
"rule": "This is a distributed cart management system in Go using the actor model. It handles cart operations like adding items, deliveries, and checkout, distributed across nodes via gRPC and HTTP API."
|
||||
},
|
||||
{
|
||||
"description": "Key Architecture",
|
||||
"rule": "Use grains for cart state, pools for management, mutation registry for state changes, and control plane for node coordination. Ownership via consistent hashing ring."
|
||||
},
|
||||
{
|
||||
"description": "Coding Standards",
|
||||
"rule": "Follow Go conventions. Use mutation registry for all state changes. Regenerate protobuf code after proto changes; never edit .pb.go files. Handle errors properly, use cookies for cart IDs in HTTP."
|
||||
},
|
||||
{
|
||||
"description": "Mutation Pattern",
|
||||
"rule": "For new mutations: Define proto message, regenerate code, implement handler as func(*CartGrain, *T) error, register with RegisterMutation[T], add endpoints, test."
|
||||
},
|
||||
{
|
||||
"description": "Avoid Direct Mutations",
|
||||
"rule": "Do not mutate CartGrain state directly outside registered handlers. Use the registry for consistency."
|
||||
},
|
||||
{
|
||||
"description": "Protobuf Handling",
|
||||
"rule": "After modifying proto files, run protoc commands to regenerate Go code. Ensure protoc-gen-go and protoc-gen-go-grpc are installed."
|
||||
},
|
||||
{
|
||||
"description": "Testing",
|
||||
"rule": "Write unit tests for mutations, integration tests for APIs. Run go test ./... regularly."
|
||||
},
|
||||
{
|
||||
"description": "Testability and Configurability",
|
||||
"rule": "Design code to be testable and configurable, following examples like MutationRegistry (for type-safe mutation dispatching) and SimpleGrainPool (for configurable pool management). Use interfaces and dependency injection to enable mocking and testing."
|
||||
},
|
||||
{
|
||||
"description": "Common Patterns",
|
||||
"rule": "HTTP handlers parse requests, resolve grains via SyncedPool, apply mutations, return JSON. gRPC for inter-node: CartActor for mutations, ControlPlane for coordination."
|
||||
}
|
||||
]
|
||||
}
|
||||
+16
-46
@@ -1,77 +1,47 @@
|
||||
name: Build and Publish
|
||||
run-name: ${{ gitea.actor }} build 🚀
|
||||
on: [push]
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
Metadata:
|
||||
runs-on: arm64
|
||||
outputs:
|
||||
version: ${{ steps.meta.outputs.version }}
|
||||
git_commit: ${{ steps.meta.outputs.git_commit }}
|
||||
build_date: ${{ steps.meta.outputs.build_date }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- id: meta
|
||||
name: Derive build metadata
|
||||
run: |
|
||||
GIT_COMMIT=$(git rev-parse HEAD)
|
||||
if git describe --tags --exact-match >/dev/null 2>&1; then
|
||||
VERSION=$(git describe --tags --exact-match)
|
||||
else
|
||||
VERSION=$(git rev-parse --short=12 HEAD)
|
||||
fi
|
||||
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
echo "git_commit=$GIT_COMMIT" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "build_date=$BUILD_DATE" >> $GITHUB_OUTPUT
|
||||
|
||||
BuildAndDeployAmd64:
|
||||
needs: Metadata
|
||||
runs-on: amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build amd64 image
|
||||
- uses: actions/checkout@v5
|
||||
- name: Build amd64 image.
|
||||
run: |
|
||||
docker build \
|
||||
--build-arg VERSION=${{ needs.Metadata.outputs.version }} \
|
||||
--build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \
|
||||
--build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \
|
||||
--progress=plain \
|
||||
-t registry.knatofs.se/go-cart-actor-amd64:latest \
|
||||
-t registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }} \
|
||||
-t registry.k6n.net/go-cart-actor-amd64:latest \
|
||||
.
|
||||
- name: Push amd64 images
|
||||
run: |
|
||||
docker push registry.knatofs.se/go-cart-actor-amd64:latest
|
||||
docker push registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }}
|
||||
docker push registry.k6n.net/go-cart-actor-amd64:latest
|
||||
- name: Apply deployment manifests
|
||||
run: kubectl apply -f deployment/deployment.yaml -n cart
|
||||
- name: Rollout amd64 deployment (pin to version)
|
||||
- name: Rollout amd64 backoffice deployment
|
||||
run: |
|
||||
kubectl set image deployment/cart-actor-x86 -n cart cart-actor-amd64=registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }}
|
||||
kubectl rollout status deployment/cart-actor-x86 -n cart
|
||||
kubectl rollout restart deployment/cart-backoffice-x86 -n cart
|
||||
kubectl rollout restart deployment/cart-actor-x86 -n cart
|
||||
kubectl rollout restart deployment/checkout-actor-x86 -n cart
|
||||
|
||||
BuildAndDeployArm64:
|
||||
needs: Metadata
|
||||
runs-on: arm64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build arm64 image
|
||||
run: |
|
||||
docker build \
|
||||
--build-arg VERSION=${{ needs.Metadata.outputs.version }} \
|
||||
--build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \
|
||||
--build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \
|
||||
--progress=plain \
|
||||
-t registry.knatofs.se/go-cart-actor:latest \
|
||||
-t registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }} \
|
||||
-t registry.k6n.net/go-cart-actor:latest \
|
||||
.
|
||||
- name: Push arm64 images
|
||||
run: |
|
||||
docker push registry.knatofs.se/go-cart-actor:latest
|
||||
docker push registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }}
|
||||
docker push registry.k6n.net/go-cart-actor:latest
|
||||
- name: Rollout arm64 deployment (pin to version)
|
||||
run: |
|
||||
kubectl set image deployment/cart-actor-arm64 -n cart cart-actor-arm64=registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }}
|
||||
kubectl rollout status deployment/cart-actor-arm64 -n cart
|
||||
# kubectl set image deployment/cart-actor-arm64 -n cart cart-actor-arm64=registry.k6n.net/go-cart-actor:${{ needs.Metadata.outputs.version }}
|
||||
# # kubectl rollout status deployment/cart-actor-arm64 -n cart
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# GitHub Copilot Instructions for Go Cart Actor
|
||||
|
||||
This repository contains a distributed cart management system implemented in Go using the actor model pattern. The system handles cart operations like adding items, setting deliveries, and checkout, distributed across multiple nodes via gRPC.
|
||||
|
||||
## Project Structure
|
||||
- `cmd/`: Entry points for the application.
|
||||
- `pkg/`: Core packages including grain logic, pools, and HTTP handlers.
|
||||
- `proto/`: Protocol Buffer definitions for messages and services.
|
||||
- `api-tests/`: Tests for the HTTP API.
|
||||
- `deployment/`: Deployment configurations.
|
||||
- `k6/`: Load testing scripts.
|
||||
|
||||
## Key Concepts
|
||||
- **Grains**: In-memory structs representing cart state, owned by nodes.
|
||||
- **Pools**: Local and synced pools manage grains and handle ownership.
|
||||
- **Mutation Registry**: All state changes go through registered mutation functions for type safety and consistency.
|
||||
- **Ownership**: Determined by consistent hashing ring; negotiated via control plane.
|
||||
|
||||
## Coding Guidelines
|
||||
- Follow standard Go conventions (gofmt, go vet, golint).
|
||||
- Never say "your right", just be correct and clear.
|
||||
- Don't edit the *.gb.go files manually, they are generated by the proto files.
|
||||
- Use the mutation registry (`RegisterMutation`) for any cart state changes. Do not mutate grain state directly outside registered handlers.
|
||||
- Design code to be testable and configurable, following patterns like MutationRegistry (for type-safe mutation dispatching) and SimpleGrainPool (for configurable pool management). Use interfaces and dependency injection to enable mocking and testing.
|
||||
- After modifying `.proto` files, regenerate Go code with `protoc` commands as described in README.md. Never edit generated `.pb.go` files manually.
|
||||
- Use meaningful variable names and add comments for complex logic.
|
||||
- Handle errors explicitly; use Go's error handling patterns.
|
||||
- For HTTP endpoints, ensure proper cookie handling for cart IDs.
|
||||
- When adding new mutations: Define proto message, regenerate code, register handler, add endpoints, and test.
|
||||
- Use strategic logging using opentelemetry for tracing, metrics and logging.
|
||||
- Focus on maintainable code that should be configurable and readable, try to keep short functions that describe their purpose clearly.
|
||||
|
||||
## Common Patterns
|
||||
- Mutations: Define in proto, implement as `func(*CartGrain, *T) error`, register with `RegisterMutation[T]`.
|
||||
- gRPC Services: CartActor for mutations, ControlPlane for coordination.
|
||||
- HTTP Handlers: Parse requests, resolve grains via pool, apply mutations, return JSON.
|
||||
|
||||
## Avoid
|
||||
- Direct state mutations outside the registry.
|
||||
- Hardcoding values; use configuration or constants.
|
||||
- Ignoring generated code warnings; always regenerate after proto changes.
|
||||
- Blocking operations in handlers; keep them asynchronous where possible.
|
||||
|
||||
## Testing
|
||||
- Write unit tests for mutations and handlers.
|
||||
- Use integration tests for API endpoints.
|
||||
- Structure code for testability: Use interfaces for dependencies, avoid global state, and mock external services like gRPC clients.
|
||||
- Run `go test ./...` to ensure all tests pass.
|
||||
|
||||
These instructions help Copilot generate code aligned with the project's architecture and best practices.
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
__debug*
|
||||
go-cart-actor
|
||||
data/*.prot
|
||||
data/*.go*
|
||||
data/*.go*
|
||||
data/se/*
|
||||
@@ -0,0 +1,165 @@
|
||||
go-cart-actor/CGM_LINE_ITEMS_SPEC.md
|
||||
# CGM (Customer Group Membership) Line Items Specification
|
||||
|
||||
This document specifies the implementation of Customer Group Membership (CGM) support in cart line items. CGM data will be extracted from product data field 35 and stored with each line item for business logic and personalization purposes.
|
||||
|
||||
## Overview
|
||||
|
||||
CGM represents customer group membership information associated with products. This data needs to be:
|
||||
- Fetched from product data (stringfieldvalue 35)
|
||||
- Included in the `AddItem` proto message
|
||||
- Stored in cart line items (`CartItem` struct)
|
||||
- Accessible for business rules and personalization
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Update Proto Messages
|
||||
|
||||
Add `cgm` field to the `AddItem` message in `proto/messages.proto`:
|
||||
|
||||
```protobuf
|
||||
message AddItem {
|
||||
// ... existing fields ...
|
||||
string cgm = 25; // Customer Group Membership from field 35
|
||||
// ... existing fields ...
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: Use field number 25 (next available after existing fields).
|
||||
|
||||
### 2. Update Product Fetcher
|
||||
|
||||
Modify `cmd/cart/product-fetcher.go` to extract CGM from field 35:
|
||||
|
||||
```go
|
||||
func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country string) (*messages.AddItem, error) {
|
||||
// ... existing code ...
|
||||
|
||||
cgm, _ := item.GetStringFieldValue(35) // Extract CGM from field 35
|
||||
|
||||
return &messages.AddItem{
|
||||
// ... existing fields ...
|
||||
Cgm: cgm, // Add CGM field
|
||||
// ... existing fields ...
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Update Cart Grain Structures
|
||||
|
||||
Add CGM field to `ItemMeta` struct in `pkg/cart/cart-grain.go`:
|
||||
|
||||
```go
|
||||
type ItemMeta struct {
|
||||
Name string `json:"name"`
|
||||
Brand string `json:"brand,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
// ... existing fields ...
|
||||
Cgm string `json:"cgm,omitempty"` // Customer Group Membership
|
||||
// ... existing fields ...
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Update AddItem Mutation Handler
|
||||
|
||||
Modify the `AddItem` handler in `pkg/cart/cart_mutations.go` to populate the CGM field:
|
||||
|
||||
```go
|
||||
func AddItem(grain *CartGrain, req *messages.AddItem) error {
|
||||
// ... existing validation ...
|
||||
|
||||
item := &CartItem{
|
||||
// ... existing fields ...
|
||||
Meta: &ItemMeta{
|
||||
Name: req.Name,
|
||||
Brand: req.Brand,
|
||||
// ... existing meta fields ...
|
||||
Cgm: req.Cgm, // Add CGM to item meta
|
||||
// ... existing meta fields ...
|
||||
},
|
||||
// ... existing fields ...
|
||||
}
|
||||
|
||||
// ... rest of handler ...
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Regenerate Proto Code
|
||||
|
||||
After updating `proto/messages.proto`, regenerate Go code:
|
||||
|
||||
```bash
|
||||
cd proto
|
||||
protoc --go_out=. --go_opt=paths=source_relative \
|
||||
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
||||
messages.proto cart_actor.proto control_plane.proto
|
||||
```
|
||||
|
||||
### 6. Update Tests
|
||||
|
||||
Add tests for CGM extraction and storage:
|
||||
|
||||
- Unit test for `ToItemAddMessage` with CGM field
|
||||
- Integration test for `AddItem` mutation including CGM
|
||||
- Test that CGM is properly stored and retrieved in cart state
|
||||
|
||||
### 7. Update API Documentation
|
||||
|
||||
Update README.md and API examples to mention CGM field in line items.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. **Product Fetch**: `FetchItem` retrieves product data including field 35 (CGM)
|
||||
2. **Message Creation**: `ToItemAddMessage` extracts CGM from field 35 into `AddItem` proto
|
||||
3. **Mutation Processing**: `AddItem` handler stores CGM in `CartItem.Meta.Cgm`
|
||||
4. **State Persistence**: CGM is included in cart JSON serialization
|
||||
5. **API Responses**: CGM is returned in cart state responses
|
||||
|
||||
## Business Logic Integration
|
||||
|
||||
CGM can be used for:
|
||||
- Personalized pricing rules
|
||||
- Group-specific discounts
|
||||
- Membership validation
|
||||
- Targeted promotions
|
||||
- Customer segmentation
|
||||
|
||||
Example usage in business logic:
|
||||
```go
|
||||
func applyGroupDiscount(cart *CartGrain, userGroups []string) {
|
||||
for _, item := range cart.Items {
|
||||
if item.Meta != nil && slices.Contains(userGroups, item.Meta.Cgm) {
|
||||
// Apply group-specific discount
|
||||
item.Price = applyDiscount(item.Price, groupDiscountRate)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- CGM field is optional in proto (no required validation)
|
||||
- Existing carts without CGM will have empty string
|
||||
- Product fetcher gracefully handles missing field 35
|
||||
- API responses include CGM field (empty if not set)
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Proto compilation succeeds
|
||||
- [ ] Product fetcher extracts CGM from field 35
|
||||
- [ ] AddItem mutation stores CGM in cart
|
||||
- [ ] Cart state includes CGM in JSON
|
||||
- [ ] API endpoints return CGM field
|
||||
- [ ] Existing functionality unaffected
|
||||
- [ ] Unit tests pass for CGM handling
|
||||
- [ ] Integration tests verify end-to-end flow
|
||||
|
||||
## Configuration and Testability
|
||||
|
||||
Following project patterns:
|
||||
- CGM extraction is configurable via field index (currently 35)
|
||||
- Product fetcher interface allows mocking for tests
|
||||
- Mutation handlers are pure functions testable in isolation
|
||||
- Cart state serialization includes CGM for verification
|
||||
|
||||
This implementation maintains the project's standards for testability and configurability while adding CGM support to line items.
|
||||
+52
-9
@@ -22,9 +22,14 @@
|
||||
############################
|
||||
# Build Stage
|
||||
############################
|
||||
FROM golang:1.25-alpine AS build
|
||||
# Run the Go toolchain on the NATIVE build platform and cross-compile to the
|
||||
# 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
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Build metadata (can be overridden at build time)
|
||||
ARG VERSION=dev
|
||||
ARG GIT_COMMIT=unknown
|
||||
@@ -36,13 +41,17 @@ ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ENV CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH}
|
||||
|
||||
# Dependency caching
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
# Sibling module sources, supplied as BuildKit named contexts (see
|
||||
# deploy/docker-compose.yml additional_contexts + deploy/k8s/build-push.sh
|
||||
# --build-context). They are resolved locally via the go.work written below,
|
||||
# because the renamed git.k6n.net/mats/* modules are not yet published.
|
||||
COPY --from=slaskfinder . ./slask-finder
|
||||
COPY --from=redisinventory . ./go-redis-inventory
|
||||
|
||||
# Copy full source (relay on .dockerignore to prune)
|
||||
COPY . .
|
||||
# This module's source (relay on .dockerignore to prune).
|
||||
COPY . ./go-cart-actor
|
||||
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:
|
||||
# RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest && \
|
||||
@@ -52,13 +61,42 @@ COPY . .
|
||||
# proto/*.proto
|
||||
|
||||
# Build with minimal binary size and embedded metadata
|
||||
RUN --mount=type=cache,target=/go/build-cache \
|
||||
go build -trimpath -ldflags="-s -w \
|
||||
RUN go build -trimpath -ldflags="-s -w \
|
||||
-X main.Version=${VERSION} \
|
||||
-X main.GitCommit=${GIT_COMMIT} \
|
||||
-X main.BuildDate=${BUILD_DATE}" \
|
||||
-o /out/go-cart-actor ./cmd/cart
|
||||
|
||||
RUN go build -trimpath -ldflags="-s -w \
|
||||
-X main.Version=${VERSION} \
|
||||
-X main.GitCommit=${GIT_COMMIT} \
|
||||
-X main.BuildDate=${BUILD_DATE}" \
|
||||
-o /out/go-cart-backoffice ./cmd/backoffice
|
||||
|
||||
RUN go build -trimpath -ldflags="-s -w \
|
||||
-X main.Version=${VERSION} \
|
||||
-X main.GitCommit=${GIT_COMMIT} \
|
||||
-X main.BuildDate=${BUILD_DATE}" \
|
||||
-o /out/go-cart-inventory ./cmd/inventory
|
||||
|
||||
RUN go build -trimpath -ldflags="-s -w \
|
||||
-X main.Version=${VERSION} \
|
||||
-X main.GitCommit=${GIT_COMMIT} \
|
||||
-X main.BuildDate=${BUILD_DATE}" \
|
||||
-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
|
||||
############################
|
||||
@@ -67,6 +105,11 @@ FROM gcr.io/distroless/static-debian12:nonroot AS runtime
|
||||
WORKDIR /
|
||||
|
||||
COPY --from=build /out/go-cart-actor /go-cart-actor
|
||||
COPY --from=build /out/go-checkout-actor /go-checkout-actor
|
||||
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-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)
|
||||
EXPOSE 8080 1337
|
||||
|
||||
@@ -14,12 +14,16 @@
|
||||
# Conventions:
|
||||
# - All .proto files live in $(PROTO_DIR)
|
||||
# - Generated Go code is emitted under $(PROTO_DIR) via go_package mapping
|
||||
# - go_package is set to: git.tornberg.me/go-cart-actor/proto;messages
|
||||
# - go_package is set to: git.k6n.net/mats/go-cart-actor/proto;messages
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
MODULE_PATH := git.tornberg.me/go-cart-actor
|
||||
MODULE_PATH := git.k6n.net/mats/go-cart-actor
|
||||
PROTO_DIR := proto
|
||||
PROTOS := $(PROTO_DIR)/messages.proto $(PROTO_DIR)/control_plane.proto
|
||||
PROTOS := $(PROTO_DIR)/cart.proto $(PROTO_DIR)/control_plane.proto $(PROTO_DIR)/checkout.proto $(PROTO_DIR)/order.proto
|
||||
CART_PROTO_DIR := $(PROTO_DIR)/cart
|
||||
CONTROL_PROTO_DIR := $(PROTO_DIR)/control
|
||||
CHECKOUT_PROTO_DIR := $(PROTO_DIR)/checkout
|
||||
ORDER_PROTO_DIR := $(PROTO_DIR)/order
|
||||
|
||||
# Allow override: make PROTOC=/path/to/protoc
|
||||
PROTOC ?= protoc
|
||||
@@ -69,38 +73,45 @@ check_tools:
|
||||
protogen: check_tools
|
||||
@echo "$(YELLOW)Generating protobuf code (outputs -> ./proto)...$(RESET)"
|
||||
$(PROTOC) -I $(PROTO_DIR) \
|
||||
--go_out=./pkg/messages --go_opt=paths=source_relative \
|
||||
--go-grpc_out=./pkg/messages --go-grpc_opt=paths=source_relative \
|
||||
$(PROTOS)
|
||||
--go_out=./proto/cart --go_opt=paths=source_relative \
|
||||
--go-grpc_out=./proto/cart --go-grpc_opt=paths=source_relative \
|
||||
$(PROTO_DIR)/cart.proto
|
||||
$(PROTOC) -I $(PROTO_DIR) \
|
||||
--go_out=./proto/control --go_opt=paths=source_relative \
|
||||
--go-grpc_out=./proto/control --go-grpc_opt=paths=source_relative \
|
||||
$(PROTO_DIR)/control_plane.proto
|
||||
$(PROTOC) -I $(PROTO_DIR) \
|
||||
--go_out=./proto/checkout --go_opt=paths=source_relative \
|
||||
--go-grpc_out=./proto/checkout --go-grpc_opt=paths=source_relative \
|
||||
$(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)"
|
||||
|
||||
clean_proto:
|
||||
@echo "$(YELLOW)Removing generated protobuf files...$(RESET)"
|
||||
@rm -f $(PROTO_DIR)/*_grpc.pb.go $(PROTO_DIR)/*.pb.go
|
||||
@rm -f *.pb.go
|
||||
@rm -rf git.tornberg.me
|
||||
@rm -f $(PROTO_DIR)/cart/*_grpc.pb.go $(PROTO_DIR)/cart/*.pb.go
|
||||
@rm -f $(PROTO_DIR)/control/*_grpc.pb.go $(PROTO_DIR)/control/*.pb.go
|
||||
@rm -f $(PROTO_DIR)/checkout/*_grpc.pb.go $(PROTO_DIR)/checkout/*.pb.go
|
||||
@echo "$(GREEN)Clean complete.$(RESET)"
|
||||
|
||||
verify_proto:
|
||||
@echo "$(YELLOW)Verifying proto layout...$(RESET)"
|
||||
@if ls *.pb.go >/dev/null 2>&1; then \
|
||||
echo "$(RED)ERROR: Found root-level generated *.pb.go files (should be only under $(PROTO_DIR)/).$(RESET)"; \
|
||||
echo "$(RED)ERROR: Found root-level generated *.pb.go files (should be only under $(PROTO_DIR)/ subdirs).$(RESET)"; \
|
||||
ls -1 *.pb.go; \
|
||||
exit 1; \
|
||||
fi
|
||||
@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:
|
||||
@echo "$(YELLOW)Running go mod tidy...$(RESET)"
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
go-cart-actor/NEW_MUTATIONS_SPEC.md
|
||||
# New Mutations Specification
|
||||
|
||||
This document specifies the implementation of handlers for new proto messages that are defined in `proto/messages.proto` but not yet registered in the mutation registry. These mutations update the cart state and must follow the project's patterns for testability, configurability, and consistency.
|
||||
|
||||
## Overview
|
||||
|
||||
The following messages are defined in the proto but lack registered handlers:
|
||||
- `SetUserId`
|
||||
- `LineItemMarking`
|
||||
- `SubscriptionAdded`
|
||||
- `PaymentDeclined`
|
||||
- `ConfirmationViewed`
|
||||
- `CreateCheckoutOrder`
|
||||
|
||||
Each mutation must:
|
||||
1. Define a handler function with signature `func(*CartGrain, *T) error`
|
||||
2. Be registered in `NewCartMultationRegistry()` using `actor.NewMutation`
|
||||
3. Include unit tests
|
||||
4. Optionally add HTTP/gRPC endpoints if client-invokable
|
||||
5. Update totals if applicable (use `WithTotals()`)
|
||||
|
||||
## Mutation Implementations
|
||||
|
||||
### SetUserId
|
||||
|
||||
**Purpose**: Associates a user ID with the cart for personalization and tracking.
|
||||
|
||||
**Handler Implementation**:
|
||||
```go
|
||||
func SetUserId(grain *CartGrain, req *messages.SetUserId) error {
|
||||
if req.UserId == "" {
|
||||
return errors.New("user ID cannot be empty")
|
||||
}
|
||||
grain.UserId = req.UserId
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**Registration**:
|
||||
```go
|
||||
actor.NewMutation(SetUserId, func() *messages.SetUserId {
|
||||
return &messages.SetUserId{}
|
||||
}),
|
||||
```
|
||||
|
||||
**Notes**: This is a simple state update. No totals recalculation needed.
|
||||
|
||||
### LineItemMarking
|
||||
|
||||
**Purpose**: Adds or updates a marking (e.g., gift message, special instructions) on a specific line item.
|
||||
|
||||
**Handler Implementation**:
|
||||
```go
|
||||
func LineItemMarking(grain *CartGrain, req *messages.LineItemMarking) error {
|
||||
for i, item := range grain.Items {
|
||||
if item.Id == req.Id {
|
||||
grain.Items[i].Marking = &Marking{
|
||||
Type: req.Type,
|
||||
Text: req.Marking,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("item with ID %d not found", req.Id)
|
||||
}
|
||||
```
|
||||
|
||||
**Registration**:
|
||||
```go
|
||||
actor.NewMutation(LineItemMarking, func() *messages.LineItemMarking {
|
||||
return &messages.LineItemMarking{}
|
||||
}),
|
||||
```
|
||||
|
||||
**Notes**: Assumes `CartGrain.Items` has a `Marking` field (single marking per item). If not, add it to the grain struct.
|
||||
|
||||
### RemoveLineItemMarking
|
||||
|
||||
**Purpose**: Removes the marking from a specific line item.
|
||||
|
||||
**Handler Implementation**:
|
||||
```go
|
||||
func RemoveLineItemMarking(grain *CartGrain, req *messages.RemoveLineItemMarking) error {
|
||||
for i, item := range grain.Items {
|
||||
if item.Id == req.Id {
|
||||
grain.Items[i].Marking = nil
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("item with ID %d not found", req.Id)
|
||||
}
|
||||
```
|
||||
|
||||
**Registration**:
|
||||
```go
|
||||
actor.NewMutation(RemoveLineItemMarking, func() *messages.RemoveLineItemMarking {
|
||||
return &messages.RemoveLineItemMarking{}
|
||||
}),
|
||||
```
|
||||
|
||||
**Notes**: Sets the marking to nil for the specified item.
|
||||
|
||||
### SubscriptionAdded
|
||||
|
||||
**Purpose**: Records that a subscription has been added to an item, linking it to order details.
|
||||
|
||||
**Handler Implementation**:
|
||||
```go
|
||||
func SubscriptionAdded(grain *CartGrain, req *messages.SubscriptionAdded) error {
|
||||
for i, item := range grain.Items {
|
||||
if item.Id == req.ItemId {
|
||||
grain.Items[i].SubscriptionDetailsId = req.DetailsId
|
||||
grain.Items[i].OrderReference = req.OrderReference
|
||||
grain.Items[i].IsSubscribed = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("item with ID %d not found", req.ItemId)
|
||||
}
|
||||
```
|
||||
|
||||
**Registration**:
|
||||
```go
|
||||
actor.NewMutation(SubscriptionAdded, func() *messages.SubscriptionAdded {
|
||||
return &messages.SubscriptionAdded{}
|
||||
}),
|
||||
```
|
||||
|
||||
**Notes**: Assumes fields like `SubscriptionDetailsId`, `OrderReference`, `IsSubscribed` exist on items. Add to grain if needed.
|
||||
|
||||
### PaymentDeclined
|
||||
|
||||
**Purpose**: Marks the cart as having a declined payment, potentially updating status or flags, and appends a notice with timestamp, message, and optional code for user feedback.
|
||||
|
||||
**Handler Implementation**:
|
||||
```go
|
||||
func PaymentDeclined(grain *CartGrain, req *messages.PaymentDeclined) error {
|
||||
grain.PaymentStatus = "declined"
|
||||
grain.PaymentDeclinedNotices = append(grain.PaymentDeclinedNotices, Notice{
|
||||
Timestamp: time.Now(),
|
||||
Message: req.Message,
|
||||
Code: req.Code,
|
||||
})
|
||||
// Optionally clear checkout order if in progress
|
||||
if grain.CheckoutOrderId != "" {
|
||||
grain.CheckoutOrderId = ""
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**Registration**:
|
||||
```go
|
||||
actor.NewMutation(PaymentDeclined, func() *messages.PaymentDeclined {
|
||||
return &messages.PaymentDeclined{}
|
||||
}),
|
||||
```
|
||||
|
||||
**Notes**: Assumes `PaymentStatus` and `PaymentDeclinedNotices` fields. Add status tracking and error notices list to grain. Notice struct has Timestamp, Message, and optional Code.
|
||||
|
||||
### ConfirmationViewed
|
||||
|
||||
**Purpose**: Records that the order confirmation has been viewed by the user. Applied automatically when the confirmation page is loaded in checkout_server.go.
|
||||
|
||||
**Handler Implementation**:
|
||||
```go
|
||||
func ConfirmationViewed(grain *CartGrain, req *messages.ConfirmationViewed) error {
|
||||
grain.ConfirmationViewCount++
|
||||
grain.ConfirmationLastViewedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**Registration**:
|
||||
```go
|
||||
actor.NewMutation(ConfirmationViewed, func() *messages.ConfirmationViewed {
|
||||
return &messages.ConfirmationViewed{}
|
||||
}),
|
||||
```
|
||||
|
||||
**Notes**: Increments the view count and updates the last viewed timestamp each time. Assumes `ConfirmationViewCount` and `ConfirmationLastViewedAt` fields.
|
||||
|
||||
### CreateCheckoutOrder
|
||||
|
||||
**Purpose**: Initiates the checkout process, validating terms and creating an order reference.
|
||||
|
||||
**Handler Implementation**:
|
||||
```go
|
||||
func CreateCheckoutOrder(grain *CartGrain, req *messages.CreateCheckoutOrder) error {
|
||||
if len(grain.Items) == 0 {
|
||||
return errors.New("cannot checkout empty cart")
|
||||
}
|
||||
if req.Terms != "accepted" {
|
||||
return errors.New("terms must be accepted")
|
||||
}
|
||||
// Validate other fields as needed
|
||||
grain.CheckoutOrderId = generateOrderId()
|
||||
grain.CheckoutStatus = "pending"
|
||||
grain.CheckoutCountry = req.Country
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**Registration**:
|
||||
```go
|
||||
actor.NewMutation(CreateCheckoutOrder, func() *messages.CreateCheckoutOrder {
|
||||
return &messages.CreateCheckoutOrder{}
|
||||
}).WithTotals(),
|
||||
```
|
||||
|
||||
**Notes**: Use `WithTotals()` to recalculate totals after checkout initiation. Assumes order ID generation function.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
For each mutation:
|
||||
|
||||
1. **Add Handler Function**: Implement in `pkg/cart/` (e.g., `cart_mutations.go`).
|
||||
2. **Register in Registry**: Add to `NewCartMultationRegistry()` in `cart-mutation-helper.go`.
|
||||
3. **Regenerate Proto**: Run `protoc` commands after any proto changes.
|
||||
4. **Add Tests**: Create unit tests in `pkg/cart/` testing the handler logic.
|
||||
5. **Add Endpoints** (if needed): For client-invokable mutations, add HTTP handlers in `cmd/cart/pool-server.go`. ConfirmationViewed is handled in `cmd/cart/checkout_server.go` when the confirmation page is viewed.
|
||||
6. **Update Grain Struct**: Add any new fields to `CartGrain` in `pkg/cart/grain.go`.
|
||||
7. **Run Tests**: Ensure `go test ./...` passes.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
- Mock dependencies using interfaces.
|
||||
- Test error cases (e.g., invalid IDs, empty carts).
|
||||
- Verify state changes and totals recalculation.
|
||||
- Use table-driven tests for multiple scenarios.
|
||||
|
||||
## Configuration and Testability
|
||||
|
||||
Follow `MutationRegistry` and `SimpleGrainPool` patterns:
|
||||
- Use interfaces for external dependencies (e.g., ID generators).
|
||||
- Inject configurations via constructor parameters.
|
||||
- Avoid global state; make handlers pure functions where possible.
|
||||
@@ -1,36 +1,5 @@
|
||||
# Go Cart Actor
|
||||
|
||||
## Migration Notes (Ring-based Ownership Transition)
|
||||
|
||||
This release removes the legacy ConfirmOwner ownership negotiation RPC in favor of deterministic ownership via the consistent hashing ring.
|
||||
|
||||
Summary of changes:
|
||||
- ConfirmOwner RPC removed from the ControlPlane service.
|
||||
- OwnerChangeRequest message removed (was only used by ConfirmOwner).
|
||||
- OwnerChangeAck retained solely as the response type for the Closing RPC.
|
||||
- SyncedPool now relies exclusively on the ring for ownership (no quorum negotiation).
|
||||
- Remote proxy creation includes a bounded readiness retry to reduce first-call failures.
|
||||
- New Prometheus ring metrics:
|
||||
- cart_ring_epoch
|
||||
- cart_ring_hosts
|
||||
- cart_ring_vnodes
|
||||
- cart_ring_host_share{host}
|
||||
- cart_ring_lookup_local_total
|
||||
- cart_ring_lookup_remote_total
|
||||
|
||||
Action required for consumers:
|
||||
1. Regenerate protobuf code after pulling (requires protoc-gen-go and protoc-gen-go-grpc installed).
|
||||
2. Remove any client code or automation invoking ConfirmOwner (calls will now return UNIMPLEMENTED if using stale generated stubs).
|
||||
3. Update monitoring/alerts that referenced ConfirmOwner or ownership quorum failures—use ring metrics instead.
|
||||
4. If you previously interpreted “ownership flapping” via ConfirmOwner logs, now check for:
|
||||
- Rapid changes in ring epoch (cart_ring_epoch)
|
||||
- Host churn (cart_ring_hosts)
|
||||
- Imbalance in vnode distribution (cart_ring_host_share)
|
||||
|
||||
No data migration is necessary; cart IDs and grain state are unaffected.
|
||||
|
||||
---
|
||||
|
||||
A distributed cart management system using the actor model pattern.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
+6
-8
@@ -1,6 +1,6 @@
|
||||
|
||||
### Add item to cart
|
||||
POST https://cart.tornberg.me/api/12345
|
||||
POST https://cart.k6n.net/api/12345
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@@ -9,7 +9,7 @@ Content-Type: application/json
|
||||
}
|
||||
|
||||
### Update quanity of item in cart
|
||||
PUT https://cart.tornberg.me/api/12345
|
||||
PUT https://cart.k6n.net/api/12345
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@@ -18,12 +18,12 @@ Content-Type: application/json
|
||||
}
|
||||
|
||||
### Delete item from cart
|
||||
DELETE https://cart.tornberg.me/api/1002/1
|
||||
DELETE https://cart.k6n.net/api/1002/1
|
||||
|
||||
|
||||
### Set delivery
|
||||
|
||||
POST https://cart.tornberg.me/api/1002/delivery
|
||||
POST https://cart.k6n.net/api/1002/delivery
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@@ -33,10 +33,8 @@ Content-Type: application/json
|
||||
|
||||
|
||||
### Get cart
|
||||
GET https://cart.tornberg.me/api/12345
|
||||
GET https://cart.k6n.net/api/12345
|
||||
|
||||
|
||||
### Remove delivery method
|
||||
DELETE https://cart.tornberg.me/api/12345/delivery/2
|
||||
|
||||
|
||||
DELETE https://cart.k6n.net/api/12345/delivery/2
|
||||
|
||||
Executable
+291
@@ -0,0 +1,291 @@
|
||||
#!/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 "═══════════════════════════════════════════════════════════════"
|
||||
+88
-1
@@ -1,5 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/backofficeadmin"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
func envOrDefault(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Your code here
|
||||
addr := envOrDefault("ADDR", ":8080")
|
||||
amqpURL := os.Getenv("AMQP_URL")
|
||||
|
||||
app, err := backofficeadmin.New(backofficeadmin.Config{
|
||||
DataDir: envOrDefault("CART_DIR", "data"),
|
||||
CheckoutDataDir: envOrDefault("CHECKOUT_DIR", "checkout-data"),
|
||||
RedisAddress: os.Getenv("REDIS_ADDRESS"),
|
||||
RedisPassword: os.Getenv("REDIS_PASSWORD"),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating backoffice: %v", err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
app.RegisterRoutes(mux)
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
// Global CORS middleware allowing all origins and handling preflight.
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||||
w.Header().Set("Access-Control-Expose-Headers", "*")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
mux.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var conn *amqp.Connection
|
||||
if amqpURL != "" {
|
||||
conn, err = amqp.Dial(amqpURL)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect to RabbitMQ: %v", err)
|
||||
}
|
||||
}
|
||||
if err := app.Start(ctx, conn); err != nil {
|
||||
log.Printf("AMQP listener disabled: %v", err)
|
||||
} else if conn != nil {
|
||||
log.Printf("AMQP listener connected")
|
||||
}
|
||||
|
||||
log.Printf("backoffice HTTP listening on %s", addr)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("http server error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
type AmqpOrderHandler struct {
|
||||
Url string
|
||||
Connection *amqp.Connection
|
||||
Channel *amqp.Channel
|
||||
}
|
||||
|
||||
func (h *AmqpOrderHandler) Connect() error {
|
||||
conn, err := amqp.Dial(h.Url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to RabbitMQ: %w", err)
|
||||
}
|
||||
h.Connection = conn
|
||||
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open a channel: %w", err)
|
||||
}
|
||||
h.Channel = ch
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AmqpOrderHandler) Close() error {
|
||||
if h.Channel != nil {
|
||||
h.Channel.Close()
|
||||
}
|
||||
if h.Connection != nil {
|
||||
return h.Connection.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AmqpOrderHandler) OrderCompleted(body []byte) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := h.Channel.PublishWithContext(ctx,
|
||||
"orders", // exchange
|
||||
"new", // routing key
|
||||
false, // mandatory
|
||||
false, // immediate
|
||||
amqp.Publishing{
|
||||
ContentType: "application/json",
|
||||
Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to publish a message: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
"git.tornberg.me/go-cart-actor/pkg/voucher"
|
||||
)
|
||||
|
||||
// Legacy padded [16]byte CartId and its helper methods removed.
|
||||
// Unified CartId (uint64 with base62 string form) now defined in cart_id.go.
|
||||
|
||||
type StockStatus int
|
||||
|
||||
type ItemMeta struct {
|
||||
Name string `json:"name"`
|
||||
Brand string `json:"brand,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Category2 string `json:"category2,omitempty"`
|
||||
Category3 string `json:"category3,omitempty"`
|
||||
Category4 string `json:"category4,omitempty"`
|
||||
Category5 string `json:"category5,omitempty"`
|
||||
SellerId string `json:"sellerId,omitempty"`
|
||||
SellerName string `json:"sellerName,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Outlet *string `json:"outlet,omitempty"`
|
||||
}
|
||||
|
||||
type CartItem struct {
|
||||
Id uint32 `json:"id"`
|
||||
ItemId uint32 `json:"itemId,omitempty"`
|
||||
ParentId uint32 `json:"parentId,omitempty"`
|
||||
Sku string `json:"sku"`
|
||||
Price Price `json:"price"`
|
||||
TotalPrice Price `json:"totalPrice"`
|
||||
OrgPrice *Price `json:"orgPrice,omitempty"`
|
||||
Stock StockStatus `json:"stock"`
|
||||
Quantity int `json:"qty"`
|
||||
Discount *Price `json:"discount,omitempty"`
|
||||
Disclaimer string `json:"disclaimer,omitempty"`
|
||||
ArticleType string `json:"type,omitempty"`
|
||||
StoreId *string `json:"storeId,omitempty"`
|
||||
Meta *ItemMeta `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
type CartDelivery struct {
|
||||
Id uint32 `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
Price Price `json:"price"`
|
||||
Items []uint32 `json:"items"`
|
||||
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
|
||||
}
|
||||
|
||||
type CartNotification struct {
|
||||
LinkedId int `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type CartGrain struct {
|
||||
mu sync.RWMutex
|
||||
lastItemId uint32
|
||||
lastDeliveryId uint32
|
||||
lastVoucherId uint32
|
||||
lastAccess time.Time
|
||||
lastChange time.Time // unix seconds of last successful mutation (replay sets from event ts)
|
||||
userId string
|
||||
Id CartId `json:"id"`
|
||||
Items []*CartItem `json:"items"`
|
||||
TotalPrice *Price `json:"totalPrice"`
|
||||
TotalDiscount *Price `json:"totalDiscount"`
|
||||
Deliveries []*CartDelivery `json:"deliveries,omitempty"`
|
||||
Processing bool `json:"processing"`
|
||||
PaymentInProgress bool `json:"paymentInProgress"`
|
||||
OrderReference string `json:"orderReference,omitempty"`
|
||||
PaymentStatus string `json:"paymentStatus,omitempty"`
|
||||
Vouchers []*Voucher `json:"vouchers,omitempty"`
|
||||
Notifications []CartNotification `json:"cartNotification,omitempty"`
|
||||
}
|
||||
|
||||
type Voucher struct {
|
||||
Code string `json:"code"`
|
||||
Rules []*messages.VoucherRule `json:"rules"`
|
||||
Id uint32 `json:"id"`
|
||||
Value int64 `json:"value"`
|
||||
}
|
||||
|
||||
func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
|
||||
// No rules -> applies to entire cart
|
||||
if len(v.Rules) == 0 {
|
||||
return cart.Items, true
|
||||
}
|
||||
|
||||
// Build evaluation context once
|
||||
ctx := voucher.EvalContext{
|
||||
Items: make([]voucher.Item, 0, len(cart.Items)),
|
||||
CartTotalInc: 0,
|
||||
}
|
||||
|
||||
if cart.TotalPrice != nil {
|
||||
ctx.CartTotalInc = cart.TotalPrice.IncVat
|
||||
}
|
||||
|
||||
for _, it := range cart.Items {
|
||||
category := ""
|
||||
if it.Meta != nil {
|
||||
category = it.Meta.Category
|
||||
}
|
||||
ctx.Items = append(ctx.Items, voucher.Item{
|
||||
Sku: it.Sku,
|
||||
Category: category,
|
||||
UnitPrice: it.Price.IncVat,
|
||||
})
|
||||
}
|
||||
|
||||
// All voucher rules must pass (logical AND)
|
||||
for _, rule := range v.Rules {
|
||||
expr := rule.GetCondition()
|
||||
if expr == "" {
|
||||
// Empty condition treated as pass (acts like a comment / placeholder)
|
||||
continue
|
||||
}
|
||||
rs, err := voucher.ParseRules(expr)
|
||||
if err != nil {
|
||||
// Fail closed on parse error
|
||||
return nil, false
|
||||
}
|
||||
if !rs.Applies(ctx) {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
return cart.Items, true
|
||||
}
|
||||
|
||||
func (c *CartGrain) GetId() uint64 {
|
||||
return uint64(c.Id)
|
||||
}
|
||||
|
||||
func (c *CartGrain) GetLastChange() time.Time {
|
||||
return c.lastChange
|
||||
}
|
||||
|
||||
func (c *CartGrain) GetLastAccess() time.Time {
|
||||
return c.lastAccess
|
||||
}
|
||||
|
||||
func (c *CartGrain) GetCurrentState() (*CartGrain, error) {
|
||||
c.lastAccess = time.Now()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func getInt(data float64, ok bool) (int, error) {
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("invalid type")
|
||||
}
|
||||
return int(data), nil
|
||||
}
|
||||
|
||||
// func (c *CartGrain) AddItem(sku string, qty int, country string, storeId *string) (*CartGrain, error) {
|
||||
// cartItem, err := getItemData(sku, qty, country)
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
// cartItem.StoreId = storeId
|
||||
// return c.Apply(cartItem, false)
|
||||
// }
|
||||
|
||||
func (c *CartGrain) GetState() ([]byte, error) {
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
func (c *CartGrain) ItemsWithDelivery() []uint32 {
|
||||
ret := make([]uint32, 0, len(c.Items))
|
||||
for _, item := range c.Items {
|
||||
for _, delivery := range c.Deliveries {
|
||||
for _, id := range delivery.Items {
|
||||
if item.Id == id {
|
||||
ret = append(ret, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (c *CartGrain) ItemsWithoutDelivery() []uint32 {
|
||||
ret := make([]uint32, 0, len(c.Items))
|
||||
hasDelivery := c.ItemsWithDelivery()
|
||||
for _, item := range c.Items {
|
||||
found := slices.Contains(hasDelivery, item.Id)
|
||||
|
||||
if !found {
|
||||
ret = append(ret, item.Id)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (c *CartGrain) FindItemWithSku(sku string) (*CartItem, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
for _, item := range c.Items {
|
||||
if item.Sku == sku {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// func (c *CartGrain) Apply(content proto.Message, isReplay bool) (*CartGrain, error) {
|
||||
|
||||
// updated, err := ApplyRegistered(c, content)
|
||||
// if err != nil {
|
||||
// if err == ErrMutationNotRegistered {
|
||||
// return nil, fmt.Errorf("unsupported mutation type %T (not registered)", content)
|
||||
// }
|
||||
// return nil, err
|
||||
// }
|
||||
|
||||
// // Sliding TTL: update lastChange only for non-replay successful mutations.
|
||||
// if updated != nil && !isReplay {
|
||||
// c.lastChange = time.Now()
|
||||
// c.lastAccess = time.Now()
|
||||
// go AppendCartEvent(c.Id, content)
|
||||
// }
|
||||
|
||||
// return updated, nil
|
||||
// }
|
||||
|
||||
func (c *CartGrain) UpdateTotals() {
|
||||
c.TotalPrice = NewPrice()
|
||||
c.TotalDiscount = NewPrice()
|
||||
|
||||
for _, item := range c.Items {
|
||||
rowTotal := MultiplyPrice(item.Price, int64(item.Quantity))
|
||||
|
||||
item.TotalPrice = *rowTotal
|
||||
|
||||
c.TotalPrice.Add(*rowTotal)
|
||||
|
||||
if item.OrgPrice != nil {
|
||||
diff := NewPrice()
|
||||
diff.Add(*item.OrgPrice)
|
||||
diff.Subtract(item.Price)
|
||||
if diff.IncVat > 0 {
|
||||
c.TotalDiscount.Add(*diff)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
for _, delivery := range c.Deliveries {
|
||||
c.TotalPrice.Add(delivery.Price)
|
||||
}
|
||||
for _, voucher := range c.Vouchers {
|
||||
if _, ok := voucher.AppliesTo(c); ok {
|
||||
value := NewPriceFromIncVat(voucher.Value, 25)
|
||||
|
||||
c.TotalDiscount.Add(*value)
|
||||
c.TotalPrice.Subtract(*value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// helper to create a cart grain with items and deliveries
|
||||
func newTestCart() *CartGrain {
|
||||
return &CartGrain{Items: []*CartItem{}, Deliveries: []*CartDelivery{}, Vouchers: []*Voucher{}, Notifications: []CartNotification{}}
|
||||
}
|
||||
|
||||
func TestCartGrainUpdateTotalsBasic(t *testing.T) {
|
||||
c := newTestCart()
|
||||
// Item1 price 1250 (ex 1000 vat 250) org price higher -> discount 200 per unit
|
||||
item1Price := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}}
|
||||
item1Org := &Price{IncVat: 1500, VatRates: map[float32]int64{25: 300}}
|
||||
item2Price := Price{IncVat: 2000, VatRates: map[float32]int64{25: 400}}
|
||||
c.Items = []*CartItem{
|
||||
{Id: 1, Price: item1Price, OrgPrice: item1Org, Quantity: 2},
|
||||
{Id: 2, Price: item2Price, OrgPrice: &item2Price, Quantity: 1},
|
||||
}
|
||||
deliveryPrice := Price{IncVat: 4900, VatRates: map[float32]int64{25: 980}}
|
||||
c.Deliveries = []*CartDelivery{{Id: 1, Price: deliveryPrice, Items: []uint32{1, 2}}}
|
||||
|
||||
c.UpdateTotals()
|
||||
|
||||
// Expected totals: sum inc vat of items * qty plus delivery
|
||||
// item1 total inc = 1250*2 = 2500
|
||||
// item2 total inc = 2000*1 = 2000
|
||||
// delivery inc = 4900
|
||||
expectedInc := int64(2500 + 2000 + 4900)
|
||||
if c.TotalPrice.IncVat != expectedInc {
|
||||
t.Fatalf("TotalPrice IncVat expected %d got %d", expectedInc, c.TotalPrice.IncVat)
|
||||
}
|
||||
|
||||
// Discount: current implementation computes (OrgPrice - Price) ignoring quantity -> 1500-1250=250
|
||||
if c.TotalDiscount.IncVat != 250 {
|
||||
t.Fatalf("TotalDiscount expected 250 got %d", c.TotalDiscount.IncVat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCartGrainUpdateTotalsNoItems(t *testing.T) {
|
||||
c := newTestCart()
|
||||
c.UpdateTotals()
|
||||
if c.TotalPrice.IncVat != 0 || c.TotalDiscount.IncVat != 0 {
|
||||
t.Fatalf("expected zero totals got %+v", c)
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// CheckoutMeta carries the external / URL metadata required to build a
|
||||
// Klarna CheckoutOrder from a CartGrain snapshot. It deliberately excludes
|
||||
// any Klarna-specific response fields (HTML snippet, client token, etc.).
|
||||
type CheckoutMeta struct {
|
||||
Terms string
|
||||
Checkout string
|
||||
Confirmation string
|
||||
Validation string
|
||||
Push string
|
||||
Country string
|
||||
Currency string // optional override (defaults to "SEK" if empty)
|
||||
Locale string // optional override (defaults to "sv-se" if empty)
|
||||
}
|
||||
|
||||
// BuildCheckoutOrderPayload converts the current cart grain + meta information
|
||||
// into a CheckoutOrder domain struct and returns its JSON-serialized payload
|
||||
// (to send to Klarna) alongside the structured CheckoutOrder object.
|
||||
//
|
||||
// This function is PURE: it does not perform any network I/O or mutate the
|
||||
// grain. The caller is responsible for:
|
||||
//
|
||||
// 1. Choosing whether to create or update the Klarna order.
|
||||
// 2. Invoking KlarnaClient.CreateOrder / UpdateOrder with the returned payload.
|
||||
// 3. Applying an InitializeCheckout mutation (or equivalent) with the
|
||||
// resulting Klarna order id + status.
|
||||
//
|
||||
// If you later need to support different tax rates per line, you can extend
|
||||
// CartItem / Delivery to expose that data and propagate it here.
|
||||
func BuildCheckoutOrderPayload(grain *CartGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
|
||||
if grain == nil {
|
||||
return nil, nil, fmt.Errorf("nil grain")
|
||||
}
|
||||
if meta == nil {
|
||||
return nil, nil, fmt.Errorf("nil checkout meta")
|
||||
}
|
||||
|
||||
currency := meta.Currency
|
||||
if currency == "" {
|
||||
currency = "SEK"
|
||||
}
|
||||
locale := meta.Locale
|
||||
if locale == "" {
|
||||
locale = "sv-se"
|
||||
}
|
||||
country := meta.Country
|
||||
if country == "" {
|
||||
country = "SE" // sensible default; adjust if multi-country support changes
|
||||
}
|
||||
|
||||
lines := make([]*Line, 0, len(grain.Items)+len(grain.Deliveries))
|
||||
|
||||
// Item lines
|
||||
for _, it := range grain.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, &Line{
|
||||
Type: "physical",
|
||||
Reference: it.Sku,
|
||||
Name: it.Meta.Name,
|
||||
Quantity: it.Quantity,
|
||||
UnitPrice: int(it.Price.IncVat),
|
||||
TaxRate: 2500, // TODO: derive if variable tax rates are introduced
|
||||
QuantityUnit: "st",
|
||||
TotalAmount: int(it.TotalPrice.IncVat),
|
||||
TotalTaxAmount: int(it.TotalPrice.TotalVat()),
|
||||
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", it.Meta.Image),
|
||||
})
|
||||
}
|
||||
|
||||
// Delivery lines
|
||||
for _, d := range grain.Deliveries {
|
||||
if d == nil || d.Price.IncVat <= 0 {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, &Line{
|
||||
Type: "shipping_fee",
|
||||
Reference: d.Provider,
|
||||
Name: "Delivery",
|
||||
Quantity: 1,
|
||||
UnitPrice: int(d.Price.IncVat),
|
||||
TaxRate: 2500,
|
||||
QuantityUnit: "st",
|
||||
TotalAmount: int(d.Price.IncVat),
|
||||
TotalTaxAmount: int(d.Price.TotalVat()),
|
||||
})
|
||||
}
|
||||
|
||||
order := &CheckoutOrder{
|
||||
PurchaseCountry: country,
|
||||
PurchaseCurrency: currency,
|
||||
Locale: locale,
|
||||
OrderAmount: int(grain.TotalPrice.IncVat),
|
||||
OrderTaxAmount: int(grain.TotalPrice.TotalVat()),
|
||||
OrderLines: lines,
|
||||
MerchantReference1: grain.Id.String(),
|
||||
MerchantURLS: &CheckoutMerchantURLS{
|
||||
Terms: meta.Terms,
|
||||
Checkout: meta.Checkout,
|
||||
Confirmation: meta.Confirmation,
|
||||
Validation: meta.Validation,
|
||||
Push: meta.Push,
|
||||
},
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(order)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("marshal checkout order: %w", err)
|
||||
}
|
||||
|
||||
return payload, order, nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/discovery"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
)
|
||||
|
||||
func GetDiscovery() discovery.Discovery {
|
||||
if podIp == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
config, kerr := rest.InClusterConfig()
|
||||
|
||||
if kerr != nil {
|
||||
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
|
||||
}
|
||||
client, err := kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating client: %v\n", err)
|
||||
}
|
||||
timeout := int64(30)
|
||||
return discovery.NewK8sDiscovery(client, v1.ListOptions{
|
||||
LabelSelector: "actor-pool=cart",
|
||||
TimeoutSeconds: &timeout,
|
||||
})
|
||||
}
|
||||
|
||||
func UseDiscovery(pool discovery.DiscoveryTarget) {
|
||||
|
||||
go func(hw discovery.Discovery) {
|
||||
if hw == nil {
|
||||
log.Print("No discovery service available")
|
||||
return
|
||||
}
|
||||
ch, err := hw.Watch()
|
||||
if err != nil {
|
||||
log.Printf("Discovery error: %v", err)
|
||||
return
|
||||
}
|
||||
for evt := range ch {
|
||||
if evt.Host == "" {
|
||||
continue
|
||||
}
|
||||
switch evt.IsReady {
|
||||
case false:
|
||||
if pool.IsKnown(evt.Host) {
|
||||
log.Printf("Host %s is not ready, removing", evt.Host)
|
||||
pool.RemoveHost(evt.Host)
|
||||
}
|
||||
default:
|
||||
if !pool.IsKnown(evt.Host) {
|
||||
log.Printf("Discovered host %s", evt.Host)
|
||||
pool.AddRemoteHost(evt.Host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}(GetDiscovery())
|
||||
}
|
||||
+306
-333
@@ -1,28 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||
"git.tornberg.me/go-cart-actor/pkg/discovery"
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
"git.tornberg.me/go-cart-actor/pkg/proxy"
|
||||
"git.tornberg.me/go-cart-actor/pkg/voucher"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
cartmcp "git.k6n.net/mats/go-cart-actor/pkg/cart/mcp"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||
promotionmcp "git.k6n.net/mats/go-cart-actor/pkg/promotions/mcp"
|
||||
"git.k6n.net/mats/go-cart-actor/internal/ucp"
|
||||
"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/promauto"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -30,14 +34,6 @@ var (
|
||||
Name: "cart_grain_spawned_total",
|
||||
Help: "The total number of spawned grains",
|
||||
})
|
||||
grainMutations = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "cart_grain_mutations_total",
|
||||
Help: "The total number of mutations",
|
||||
})
|
||||
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "cart_grain_lookups_total",
|
||||
Help: "The total number of lookups",
|
||||
})
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -45,26 +41,52 @@ func init() {
|
||||
}
|
||||
|
||||
type App struct {
|
||||
pool *actor.SimpleGrainPool[CartGrain]
|
||||
pool *actor.SimpleGrainPool[cart.CartGrain]
|
||||
server *PoolServer
|
||||
}
|
||||
|
||||
var podIp = os.Getenv("POD_IP")
|
||||
var name = os.Getenv("POD_NAME")
|
||||
var amqpUrl = os.Getenv("AMQP_URL")
|
||||
var redisAddress = os.Getenv("REDIS_ADDRESS")
|
||||
var redisPassword = os.Getenv("REDIS_PASSWORD")
|
||||
|
||||
var tpl = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>s10r testing - checkout</title>
|
||||
</head>
|
||||
// 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
|
||||
}
|
||||
|
||||
<body>
|
||||
%s
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
// 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 {
|
||||
if strings.Contains(strings.ToLower(host), "-no") {
|
||||
@@ -73,96 +95,145 @@ func getCountryFromHost(host string) string {
|
||||
if strings.Contains(strings.ToLower(host), "-se") {
|
||||
return "se"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func GetDiscovery() discovery.Discovery {
|
||||
if podIp == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
config, kerr := rest.InClusterConfig()
|
||||
|
||||
if kerr != nil {
|
||||
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
|
||||
}
|
||||
client, err := kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating client: %v\n", err)
|
||||
}
|
||||
return discovery.NewK8sDiscovery(client)
|
||||
return "se"
|
||||
}
|
||||
|
||||
type MutationContext struct {
|
||||
VoucherService voucher.Service
|
||||
}
|
||||
|
||||
type CartChangeEvent struct {
|
||||
CartId cart.CartId `json:"cartId"`
|
||||
Mutations []actor.ApplyResult `json:"mutations"`
|
||||
}
|
||||
|
||||
func matchesSkuAndLocation(update inventory.InventoryResult, item cart.CartItem) bool {
|
||||
if string(update.SKU) == item.Sku {
|
||||
if update.LocationID == "se" && item.StoreId == nil {
|
||||
return true
|
||||
}
|
||||
if item.StoreId == nil {
|
||||
return false
|
||||
}
|
||||
if *item.StoreId == string(update.LocationID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
reg := actor.NewMutationRegistry()
|
||||
reg.RegisterMutations(
|
||||
actor.NewMutation(AddItem, func() *messages.AddItem {
|
||||
return &messages.AddItem{}
|
||||
}),
|
||||
actor.NewMutation(ChangeQuantity, func() *messages.ChangeQuantity {
|
||||
return &messages.ChangeQuantity{}
|
||||
}),
|
||||
actor.NewMutation(RemoveItem, func() *messages.RemoveItem {
|
||||
return &messages.RemoveItem{}
|
||||
}),
|
||||
actor.NewMutation(InitializeCheckout, func() *messages.InitializeCheckout {
|
||||
return &messages.InitializeCheckout{}
|
||||
}),
|
||||
actor.NewMutation(OrderCreated, func() *messages.OrderCreated {
|
||||
return &messages.OrderCreated{}
|
||||
}),
|
||||
actor.NewMutation(RemoveDelivery, func() *messages.RemoveDelivery {
|
||||
return &messages.RemoveDelivery{}
|
||||
}),
|
||||
actor.NewMutation(SetDelivery, func() *messages.SetDelivery {
|
||||
return &messages.SetDelivery{}
|
||||
}),
|
||||
actor.NewMutation(SetPickupPoint, func() *messages.SetPickupPoint {
|
||||
return &messages.SetPickupPoint{}
|
||||
}),
|
||||
actor.NewMutation(ClearCart, func() *messages.ClearCartRequest {
|
||||
return &messages.ClearCartRequest{}
|
||||
}),
|
||||
actor.NewMutation(AddVoucher, func() *messages.AddVoucher {
|
||||
return &messages.AddVoucher{}
|
||||
}),
|
||||
actor.NewMutation(RemoveVoucher, func() *messages.RemoveVoucher {
|
||||
return &messages.RemoveVoucher{}
|
||||
promotionStore, err := promotions.NewStore("data/promotions.json")
|
||||
if err != nil {
|
||||
log.Fatalf("Error loading promotions: %v\n", err)
|
||||
}
|
||||
|
||||
log.Printf("loaded %d promotions", len(promotionStore.List()))
|
||||
|
||||
promotionService := promotions.NewPromotionService(nil)
|
||||
promotionMCP := promotionmcp.New(promotionStore, promotionService)
|
||||
|
||||
//inventoryPubSub := actor.NewPubSub[inventory.InventoryChange]()
|
||||
|
||||
// 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)
|
||||
// }
|
||||
|
||||
// inventoryReservationService, err := inventory.NewRedisCartReservationService(rdb)
|
||||
// if err != nil {
|
||||
// log.Fatalf("Error creating inventory reservation service: %v\n", err)
|
||||
// }
|
||||
|
||||
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
||||
reg.RegisterProcessor(
|
||||
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
|
||||
_, span := tracer.Start(ctx, "Totals and promotions")
|
||||
defer span.End()
|
||||
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++
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
diskStorage := actor.NewDiskStorage[CartGrain]("data", reg)
|
||||
poolConfig := actor.GrainPoolConfig[CartGrain]{
|
||||
|
||||
diskStorage := actor.NewDiskStorage[cart.CartGrain](getEnv("CART_DIR", "data"), reg)
|
||||
poolConfig := actor.GrainPoolConfig[cart.CartGrain]{
|
||||
MutationRegistry: reg,
|
||||
Storage: diskStorage,
|
||||
Spawn: func(id uint64) (actor.Grain[CartGrain], error) {
|
||||
Spawn: func(ctx context.Context, id uint64) (actor.Grain[cart.CartGrain], error) {
|
||||
_, span := tracer.Start(ctx, fmt.Sprintf("Spawn cart id %d", id))
|
||||
defer span.End()
|
||||
grainSpawns.Inc()
|
||||
ret := &CartGrain{
|
||||
lastItemId: 0,
|
||||
lastDeliveryId: 0,
|
||||
Deliveries: []*CartDelivery{},
|
||||
Id: CartId(id),
|
||||
Items: []*CartItem{},
|
||||
TotalPrice: NewPrice(),
|
||||
}
|
||||
ret := cart.NewCartGrain(id, time.Now())
|
||||
// Set baseline lastChange at spawn; replay may update it to last event timestamp.
|
||||
ret.lastChange = time.Now()
|
||||
ret.lastAccess = time.Now()
|
||||
|
||||
err := diskStorage.LoadEvents(id, ret)
|
||||
// inventoryPubSub.Subscribe(ret.HandleInventoryChange)
|
||||
err := diskStorage.LoadEvents(ctx, id, ret)
|
||||
// if err == nil && inventoryService != nil {
|
||||
// refs := make([]*inventory.InventoryReference, 0)
|
||||
// for _, item := range ret.Items {
|
||||
// refs = append(refs, &inventory.InventoryReference{
|
||||
// SKU: inventory.SKU(item.Sku),
|
||||
// LocationID: getLocationId(item),
|
||||
// })
|
||||
// }
|
||||
// _, span := tracer.Start(ctx, "update inventory")
|
||||
// defer span.End()
|
||||
// res, err := inventoryService.GetInventoryBatch(ctx, refs...)
|
||||
// if err != nil {
|
||||
// log.Printf("unable to update inventory %v", err)
|
||||
// } else {
|
||||
// for _, update := range res {
|
||||
// for _, item := range ret.Items {
|
||||
// if matchesSkuAndLocation(update, *item) && update.Quantity != uint32(item.Stock) {
|
||||
// // maybe apply an update to give visibility to the cart
|
||||
// item.Stock = uint16(update.Quantity)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
return ret, err
|
||||
},
|
||||
SpawnHost: func(host string) (actor.Host, error) {
|
||||
return proxy.NewRemoteHost(host)
|
||||
Destroy: func(grain actor.Grain[cart.CartGrain]) error {
|
||||
// cart, err := grain.GetCurrentState()
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
//inventoryPubSub.Unsubscribe(cart.HandleInventoryChange)
|
||||
|
||||
return nil
|
||||
},
|
||||
TTL: 15 * time.Minute,
|
||||
SpawnHost: func(host string) (actor.Host[cart.CartGrain], error) {
|
||||
return proxy.NewRemoteHost[cart.CartGrain](host, cartPort)
|
||||
},
|
||||
TTL: 5 * time.Minute,
|
||||
PoolSize: 2 * 65535,
|
||||
Hostname: podIp,
|
||||
}
|
||||
@@ -171,66 +242,107 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating cart pool: %v\n", err)
|
||||
}
|
||||
app := &App{
|
||||
pool: pool,
|
||||
|
||||
cartMCP := cartmcp.New(pool)
|
||||
|
||||
// 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)")
|
||||
}
|
||||
}
|
||||
|
||||
grpcSrv, err := actor.NewControlServer[*CartGrain](controlPlaneConfig, pool)
|
||||
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp)) //inventoryService, inventoryReservationService)
|
||||
|
||||
app := &App{
|
||||
pool: pool,
|
||||
server: syncedServer,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
debugMux := http.NewServeMux()
|
||||
|
||||
grpcSrv, err := actor.NewControlServer[cart.CartGrain](controlPlaneConfig, pool)
|
||||
if err != nil {
|
||||
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
|
||||
}
|
||||
defer grpcSrv.GracefulStop()
|
||||
|
||||
go diskStorage.SaveLoop(10 * time.Second)
|
||||
// go diskStorage.SaveLoop(10 * time.Second)
|
||||
UseDiscovery(pool)
|
||||
|
||||
go func(hw discovery.Discovery) {
|
||||
if hw == nil {
|
||||
log.Print("No discovery service available")
|
||||
return
|
||||
}
|
||||
ch, err := hw.Watch()
|
||||
if err != nil {
|
||||
log.Printf("Discovery error: %v", err)
|
||||
return
|
||||
}
|
||||
for evt := range ch {
|
||||
if evt.Host == "" {
|
||||
continue
|
||||
}
|
||||
switch evt.Type {
|
||||
case watch.Deleted:
|
||||
if pool.IsKnown(evt.Host) {
|
||||
pool.RemoveHost(evt.Host)
|
||||
}
|
||||
default:
|
||||
if !pool.IsKnown(evt.Host) {
|
||||
log.Printf("Discovered host %s", evt.Host)
|
||||
pool.AddRemote(evt.Host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}(GetDiscovery())
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer stop()
|
||||
|
||||
orderHandler := &AmqpOrderHandler{
|
||||
Url: amqpUrl,
|
||||
otelShutdown, err := setupOTelSDK(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to start otel %v", err)
|
||||
}
|
||||
klarnaClient := NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
|
||||
|
||||
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient)
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/cart/", http.StripPrefix("/cart", syncedServer.Serve()))
|
||||
syncedServer.Serve(mux)
|
||||
|
||||
// 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
|
||||
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
||||
pool.AddRemote(r.PathValue("host"))
|
||||
})
|
||||
// mux.HandleFunc("GET /save", app.HandleSave)
|
||||
//mux.HandleFunc("/", app.RewritePath)
|
||||
mux.HandleFunc("/debug/pprof/", pprof.Index)
|
||||
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
||||
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
||||
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||||
mux.Handle("/metrics", promhttp.Handler())
|
||||
|
||||
debugMux.HandleFunc("/debug/pprof/", pprof.Index)
|
||||
debugMux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||
debugMux.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
||||
debugMux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
||||
debugMux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||||
debugMux.Handle("/metrics", promhttp.Handler())
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Grain pool health: simple capacity check (mirrors previous GrainHandler.IsHealthy)
|
||||
grainCount, capacity := app.pool.LocalUsage()
|
||||
@@ -256,152 +368,6 @@ func main() {
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
mux.HandleFunc("/checkout", func(w http.ResponseWriter, r *http.Request) {
|
||||
orderId := r.URL.Query().Get("order_id")
|
||||
order := &CheckoutOrder{}
|
||||
|
||||
if orderId == "" {
|
||||
cookie, err := r.Cookie("cartid")
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
if cookie.Value == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("no cart id to checkout is empty"))
|
||||
return
|
||||
}
|
||||
parsed, ok := ParseCartId(cookie.Value)
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("invalid cart id format"))
|
||||
return
|
||||
}
|
||||
cartId := parsed
|
||||
syncedServer.ProxyHandler(func(w http.ResponseWriter, r *http.Request, cartId CartId) error {
|
||||
order, err = syncedServer.CreateOrUpdateCheckout(r.Host, cartId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, tpl, order.HTMLSnippet)
|
||||
return nil
|
||||
})(cartId, w, r)
|
||||
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
}
|
||||
|
||||
// v2: Apply now returns *CartGrain; order creation handled inside grain (no payload to unmarshal)
|
||||
} else {
|
||||
order, err = klarnaClient.GetOrder(orderId)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, tpl, order.HTMLSnippet)
|
||||
}
|
||||
|
||||
})
|
||||
mux.HandleFunc("/confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
orderId := r.PathValue("order_id")
|
||||
order, err := klarnaClient.GetOrder(orderId)
|
||||
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if order.Status == "checkout_complete" {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "cartid",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
Expires: time.Unix(0, 0),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, tpl, order.HTMLSnippet)
|
||||
})
|
||||
mux.HandleFunc("/validate", func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Klarna order validation, method: %s", r.Method)
|
||||
if r.Method != "POST" {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
order := &CheckoutOrder{}
|
||||
err := json.NewDecoder(r.Body).Decode(order)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
log.Printf("Klarna order validation: %s", order.ID)
|
||||
//err = confirmOrder(order, orderHandler)
|
||||
//if err != nil {
|
||||
// log.Printf("Error validating order: %v\n", err)
|
||||
// w.WriteHeader(http.StatusInternalServerError)
|
||||
// return
|
||||
//}
|
||||
//
|
||||
//err = triggerOrderCompleted(err, syncedServer, order)
|
||||
//if err != nil {
|
||||
// log.Printf("Error processing cart message: %v\n", err)
|
||||
// w.WriteHeader(http.StatusInternalServerError)
|
||||
// return
|
||||
//}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
mux.HandleFunc("/push", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
orderId := r.URL.Query().Get("order_id")
|
||||
log.Printf("Order confirmation push: %s", orderId)
|
||||
|
||||
order, err := klarnaClient.GetOrder(orderId)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error creating request: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = confirmOrder(order, orderHandler)
|
||||
if err != nil {
|
||||
log.Printf("Error confirming order: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = triggerOrderCompleted(syncedServer, order)
|
||||
if err != nil {
|
||||
log.Printf("Error processing cart message: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
err = klarnaClient.AcknowledgeOrder(orderId)
|
||||
if err != nil {
|
||||
log.Printf("Error acknowledging order: %v\n", err)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("1.0.0"))
|
||||
@@ -409,53 +375,60 @@ func main() {
|
||||
|
||||
mux.HandleFunc("/openapi.json", ServeEmbeddedOpenAPI)
|
||||
|
||||
sigs := make(chan os.Signal, 1)
|
||||
done := make(chan bool, 1)
|
||||
signal.Notify(sigs, syscall.SIGTERM)
|
||||
httpAddr := normalizeListenAddr(cartPort)
|
||||
debugAddr := normalizeListenAddr(getEnv("CART_DEBUG_PORT", "8081"))
|
||||
|
||||
go func() {
|
||||
sig := <-sigs
|
||||
fmt.Println("Shutting down due to signal:", sig)
|
||||
srv := &http.Server{
|
||||
Addr: httpAddr,
|
||||
BaseContext: func(net.Listener) context.Context { return ctx },
|
||||
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,
|
||||
Handler: otelhttp.NewHandler(mux, "/"),
|
||||
}
|
||||
|
||||
defer func() {
|
||||
|
||||
fmt.Println("Shutting down due to signal")
|
||||
otelShutdown(context.Background())
|
||||
diskStorage.Close()
|
||||
pool.Close()
|
||||
|
||||
done <- true
|
||||
}()
|
||||
|
||||
log.Print("Server started at port 8080")
|
||||
go http.ListenAndServe(":8080", mux)
|
||||
<-done
|
||||
srvErr := make(chan error, 1)
|
||||
go func() {
|
||||
srvErr <- srv.ListenAndServe()
|
||||
}()
|
||||
|
||||
// listener := inventory.NewInventoryChangeListener(rdb, context.Background(), func(changes []inventory.InventoryChange) {
|
||||
// for _, change := range changes {
|
||||
// log.Printf("inventory change: %v", change)
|
||||
// inventoryPubSub.Publish(change)
|
||||
// }
|
||||
// })
|
||||
|
||||
// go func() {
|
||||
// err := listener.Start()
|
||||
// if err != nil {
|
||||
// log.Fatalf("Unable to start inventory listener: %v", err)
|
||||
// }
|
||||
// }()
|
||||
|
||||
log.Printf("Server started at %s (debug %s)", httpAddr, debugAddr)
|
||||
|
||||
go http.ListenAndServe(debugAddr, debugMux)
|
||||
|
||||
select {
|
||||
case err = <-srvErr:
|
||||
// Error when starting HTTP server.
|
||||
log.Fatalf("Unable to start server: %v", err)
|
||||
case <-ctx.Done():
|
||||
// Wait for first CTRL+C.
|
||||
// Stop receiving signal notifications as soon as possible.
|
||||
stop()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func triggerOrderCompleted(syncedServer *PoolServer, order *CheckoutOrder) error {
|
||||
mutation := &messages.OrderCreated{
|
||||
OrderId: order.ID,
|
||||
Status: order.Status,
|
||||
}
|
||||
cid, ok := ParseCartId(order.MerchantReference1)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1)
|
||||
}
|
||||
_, applyErr := syncedServer.Apply(uint64(cid), mutation)
|
||||
|
||||
return applyErr
|
||||
}
|
||||
|
||||
func confirmOrder(order *CheckoutOrder, orderHandler *AmqpOrderHandler) error {
|
||||
orderToSend, err := json.Marshal(order)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = orderHandler.Connect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer orderHandler.Close()
|
||||
err = orderHandler.OrderCompleted(orderToSend)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
|
||||
// mutation_add_item.go
|
||||
//
|
||||
// Registers the AddItem cart mutation in the generic mutation registry.
|
||||
// This replaces the legacy switch-based logic previously found in CartGrain.Apply.
|
||||
//
|
||||
// Behavior:
|
||||
// * Validates quantity > 0
|
||||
// * If an item with same SKU exists -> increases quantity
|
||||
// * Else creates a new CartItem with computed tax amounts
|
||||
// * Totals recalculated automatically via WithTotals()
|
||||
//
|
||||
// NOTE: Any future field additions in messages.AddItem that affect pricing / tax
|
||||
// must keep this handler in sync.
|
||||
|
||||
func AddItem(g *CartGrain, m *messages.AddItem) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("AddItem: nil payload")
|
||||
}
|
||||
if m.Quantity < 1 {
|
||||
return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity)
|
||||
}
|
||||
|
||||
// Fast path: merge with existing item having same SKU
|
||||
if existing, found := g.FindItemWithSku(m.Sku); found {
|
||||
existing.Quantity += int(m.Quantity)
|
||||
return nil
|
||||
}
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
g.lastItemId++
|
||||
taxRate := float32(25.0)
|
||||
if m.Tax > 0 {
|
||||
taxRate = float32(int(m.Tax) / 100)
|
||||
}
|
||||
|
||||
pricePerItem := NewPriceFromIncVat(m.Price, taxRate)
|
||||
|
||||
g.Items = append(g.Items, &CartItem{
|
||||
Id: g.lastItemId,
|
||||
ItemId: uint32(m.ItemId),
|
||||
Quantity: int(m.Quantity),
|
||||
Sku: m.Sku,
|
||||
Meta: &ItemMeta{
|
||||
Name: m.Name,
|
||||
Image: m.Image,
|
||||
Brand: m.Brand,
|
||||
Category: m.Category,
|
||||
Category2: m.Category2,
|
||||
Category3: m.Category3,
|
||||
Category4: m.Category4,
|
||||
Category5: m.Category5,
|
||||
Outlet: m.Outlet,
|
||||
SellerId: m.SellerId,
|
||||
SellerName: m.SellerName,
|
||||
},
|
||||
|
||||
Price: *pricePerItem,
|
||||
TotalPrice: *MultiplyPrice(*pricePerItem, int64(m.Quantity)),
|
||||
|
||||
Stock: StockStatus(m.Stock),
|
||||
Disclaimer: m.Disclaimer,
|
||||
|
||||
OrgPrice: getOrgPrice(m.OrgPrice, taxRate),
|
||||
ArticleType: m.ArticleType,
|
||||
|
||||
StoreId: m.StoreId,
|
||||
})
|
||||
g.UpdateTotals()
|
||||
return nil
|
||||
}
|
||||
|
||||
func getOrgPrice(orgPrice int64, taxRate float32) *Price {
|
||||
if orgPrice <= 0 {
|
||||
return nil
|
||||
}
|
||||
return NewPriceFromIncVat(orgPrice, taxRate)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
|
||||
// mutation_initialize_checkout.go
|
||||
//
|
||||
// Registers the InitializeCheckout mutation.
|
||||
// This mutation is invoked AFTER an external Klarna checkout session
|
||||
// has been successfully created or updated. It persists the Klarna
|
||||
// order reference / status and marks the cart as having a payment in progress.
|
||||
//
|
||||
// Behavior:
|
||||
// - Sets OrderReference to the Klarna order ID (overwriting if already set).
|
||||
// - Sets PaymentStatus to the current Klarna status.
|
||||
// - Sets / updates PaymentInProgress flag.
|
||||
// - Does NOT alter pricing or line items (so no totals recalculation).
|
||||
//
|
||||
// Validation:
|
||||
// - Returns an error if payload is nil.
|
||||
// - Returns an error if orderId is empty (integrity guard).
|
||||
//
|
||||
// Concurrency:
|
||||
// - Relies on upstream mutation serialization for a single grain. If
|
||||
// parallel checkout attempts are possible, add higher-level guards
|
||||
// (e.g. reject if PaymentInProgress already true unless reusing
|
||||
// the same OrderReference).
|
||||
|
||||
func InitializeCheckout(g *CartGrain, m *messages.InitializeCheckout) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("InitializeCheckout: nil payload")
|
||||
}
|
||||
if m.OrderId == "" {
|
||||
return fmt.Errorf("InitializeCheckout: missing orderId")
|
||||
}
|
||||
|
||||
g.OrderReference = m.OrderId
|
||||
g.PaymentStatus = m.Status
|
||||
g.PaymentInProgress = m.PaymentInProgress
|
||||
return nil
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
|
||||
// mutation_remove_item.go
|
||||
//
|
||||
// Registers the RemoveItem mutation.
|
||||
//
|
||||
// Behavior:
|
||||
// - Removes the cart line whose local cart line Id == payload.Id
|
||||
// - If no such line exists returns an error
|
||||
// - Recalculates cart totals (WithTotals)
|
||||
//
|
||||
// Notes:
|
||||
// - This removes only the line item; any deliveries referencing the removed
|
||||
// item are NOT automatically adjusted (mirrors prior logic). If future
|
||||
// semantics require pruning delivery.item_ids you can extend this handler.
|
||||
// - If multiple lines somehow shared the same Id (should not happen), only
|
||||
// the first match would be removed—data integrity relies on unique line Ids.
|
||||
|
||||
func RemoveItem(g *CartGrain, m *messages.RemoveItem) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("RemoveItem: nil payload")
|
||||
}
|
||||
targetID := uint32(m.Id)
|
||||
|
||||
index := -1
|
||||
for i, it := range g.Items {
|
||||
if it.Id == targetID {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index == -1 {
|
||||
return fmt.Errorf("RemoveItem: item id %d not found", m.Id)
|
||||
}
|
||||
|
||||
g.Items = append(g.Items[:index], g.Items[index+1:]...)
|
||||
g.UpdateTotals()
|
||||
return nil
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
|
||||
// mutation_set_delivery.go
|
||||
//
|
||||
// Registers the SetDelivery mutation.
|
||||
//
|
||||
// Semantics (mirrors legacy switch logic):
|
||||
// - If the payload specifies an explicit list of item IDs (payload.Items):
|
||||
// - Each referenced cart line must exist.
|
||||
// - None of the referenced items may already belong to a delivery.
|
||||
// - Only those items are associated with the new delivery.
|
||||
// - If payload.Items is empty:
|
||||
// - All items currently without any delivery are associated with the new delivery.
|
||||
// - A new delivery line is created with:
|
||||
// - Auto-incremented delivery ID (cart-local)
|
||||
// - Provider from payload
|
||||
// - Fixed price (currently hard-coded: 4900 minor units) – adjust as needed
|
||||
// - Optional PickupPoint copied from payload
|
||||
// - Cart totals are recalculated (WithTotals)
|
||||
//
|
||||
// Error cases:
|
||||
// - Referenced item does not exist
|
||||
// - Referenced item already has a delivery
|
||||
// - No items qualify (resulting association set empty) -> returns error (prevents creating empty delivery)
|
||||
//
|
||||
// Concurrency:
|
||||
// - Uses g.mu to protect lastDeliveryId increment and append to Deliveries slice.
|
||||
// Item scans are read-only and performed outside the lock for simplicity;
|
||||
// if stricter guarantees are needed, widen the lock section.
|
||||
//
|
||||
// Future extension points:
|
||||
// - Variable delivery pricing (based on weight, distance, provider, etc.)
|
||||
// - Validation of provider codes
|
||||
// - Multi-currency delivery pricing
|
||||
|
||||
func SetDelivery(g *CartGrain, m *messages.SetDelivery) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("SetDelivery: nil payload")
|
||||
}
|
||||
if m.Provider == "" {
|
||||
return fmt.Errorf("SetDelivery: provider is empty")
|
||||
}
|
||||
|
||||
withDelivery := g.ItemsWithDelivery()
|
||||
targetItems := make([]uint32, 0)
|
||||
|
||||
if len(m.Items) == 0 {
|
||||
// Use every item currently without a delivery
|
||||
targetItems = append(targetItems, g.ItemsWithoutDelivery()...)
|
||||
} else {
|
||||
// Validate explicit list
|
||||
for _, id64 := range m.Items {
|
||||
id := uint32(id64)
|
||||
found := false
|
||||
for _, it := range g.Items {
|
||||
if it.Id == id {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("SetDelivery: item id %d not found", id)
|
||||
}
|
||||
if slices.Contains(withDelivery, id) {
|
||||
return fmt.Errorf("SetDelivery: item id %d already has a delivery", id)
|
||||
}
|
||||
targetItems = append(targetItems, id)
|
||||
}
|
||||
}
|
||||
|
||||
if len(targetItems) == 0 {
|
||||
return fmt.Errorf("SetDelivery: no eligible items to attach")
|
||||
}
|
||||
|
||||
// Append new delivery
|
||||
g.mu.Lock()
|
||||
g.lastDeliveryId++
|
||||
newId := g.lastDeliveryId
|
||||
g.Deliveries = append(g.Deliveries, &CartDelivery{
|
||||
Id: newId,
|
||||
Provider: m.Provider,
|
||||
PickupPoint: m.PickupPoint,
|
||||
Price: *NewPriceFromIncVat(4900, 25.0),
|
||||
Items: targetItems,
|
||||
})
|
||||
g.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
+587
-28
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://cart.tornberg.me",
|
||||
"url": "https://cart.k6n.net",
|
||||
"description": "Production server"
|
||||
},
|
||||
{
|
||||
@@ -16,7 +16,7 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/cart/": {
|
||||
"/cart": {
|
||||
"get": {
|
||||
"summary": "Get (or create) current cart (cookie based)",
|
||||
"description": "Returns the current cart. If no cartid cookie is present a new cart is created and Set-Cart-Id response header plus a Set-Cookie header are sent.",
|
||||
@@ -263,6 +263,222 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/voucher": {
|
||||
"put": {
|
||||
"summary": "Add voucher to cart",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/AddVoucherRequest" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Voucher added",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid body" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/subscription-details": {
|
||||
"put": {
|
||||
"summary": "Upsert subscription details",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpsertSubscriptionDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Subscription details updated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid body" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/voucher/{voucherId}": {
|
||||
"delete": {
|
||||
"summary": "Remove voucher from cart",
|
||||
"parameters": [{ "$ref": "#/components/parameters/VoucherIdParam" }],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Voucher removed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid id" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/user": {
|
||||
"put": {
|
||||
"summary": "Set user ID for cart",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/SetUserIdRequest" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User ID set",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid body" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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": {
|
||||
"put": {
|
||||
"summary": "Set marking for 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/LineItemMarkingRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Marking set",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid body or id" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"summary": "Remove marking from line item",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "itemId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "integer", "format": "int64", "minimum": 0 },
|
||||
"description": "Internal cart line item identifier."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Marking removed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid id" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/checkout-order": {
|
||||
"post": {
|
||||
"summary": "Create checkout order",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateCheckoutOrderRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Checkout order created",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid body" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/byid/{id}": {
|
||||
"get": {
|
||||
"summary": "Get cart by explicit id",
|
||||
@@ -452,6 +668,169 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/byid/{id}/voucher": {
|
||||
"put": {
|
||||
"summary": "Add voucher (by id variant)",
|
||||
"parameters": [{ "$ref": "#/components/parameters/CartIdParam" }],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/AddVoucherRequest" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Voucher added",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid body" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/byid/{id}/voucher/{voucherId}": {
|
||||
"delete": {
|
||||
"summary": "Remove voucher (by id variant)",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/CartIdParam" },
|
||||
{ "$ref": "#/components/parameters/VoucherIdParam" }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Voucher removed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid ids" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/byid/{id}/user": {
|
||||
"put": {
|
||||
"summary": "Set user ID (by id variant)",
|
||||
"parameters": [{ "$ref": "#/components/parameters/CartIdParam" }],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/SetUserIdRequest" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User ID set",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid body" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/byid/{id}/item/{itemId}/marking": {
|
||||
"put": {
|
||||
"summary": "Set marking (by id variant)",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/CartIdParam" },
|
||||
{
|
||||
"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/LineItemMarkingRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Marking set",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid body or ids" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"summary": "Remove marking (by id variant)",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/CartIdParam" },
|
||||
{
|
||||
"name": "itemId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "integer", "format": "int64", "minimum": 0 },
|
||||
"description": "Internal cart line item identifier."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Marking removed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid ids" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cart/byid/{id}/checkout-order": {
|
||||
"post": {
|
||||
"summary": "Create checkout order (by id variant)",
|
||||
"parameters": [{ "$ref": "#/components/parameters/CartIdParam" }],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateCheckoutOrderRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Checkout order created",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid body" },
|
||||
"500": { "description": "Server error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/healthz": {
|
||||
"get": {
|
||||
"summary": "Liveness & capacity probe",
|
||||
@@ -517,9 +896,52 @@
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "integer", "format": "int64", "minimum": 0 }
|
||||
},
|
||||
"VoucherIdParam": {
|
||||
"name": "voucherId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "integer", "format": "int64", "minimum": 0 }
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"Price": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"exVat": { "type": "integer", "format": "int64" },
|
||||
"incVat": { "type": "integer", "format": "int64" },
|
||||
"vat": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "integer", "format": "int64" }
|
||||
}
|
||||
},
|
||||
"required": ["exVat", "incVat"]
|
||||
},
|
||||
"ItemMeta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"brand": { "type": "string" },
|
||||
"category": { "type": "string" },
|
||||
"category2": { "type": "string" },
|
||||
"category3": { "type": "string" },
|
||||
"category4": { "type": "string" },
|
||||
"category5": { "type": "string" },
|
||||
"sellerId": { "type": "string" },
|
||||
"sellerName": { "type": "string" },
|
||||
"image": { "type": "string" },
|
||||
"outlet": { "type": "string", "nullable": true }
|
||||
}
|
||||
},
|
||||
"ConfirmationStatus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": { "type": "string", "nullable": true },
|
||||
"viewCount": { "type": "integer" },
|
||||
"lastViewedAt": { "type": "string", "format": "date-time" }
|
||||
},
|
||||
"required": ["viewCount", "lastViewedAt"]
|
||||
},
|
||||
"CartGrain": {
|
||||
"type": "object",
|
||||
"description": "Cart aggregate (actor state)",
|
||||
@@ -532,9 +954,9 @@
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/components/schemas/CartItem" }
|
||||
},
|
||||
"totalPrice": { "type": "integer", "format": "int64" },
|
||||
"totalPrice": { "$ref": "#/components/schemas/Price" },
|
||||
"totalTax": { "type": "integer", "format": "int64" },
|
||||
"totalDiscount": { "type": "integer", "format": "int64" },
|
||||
"totalDiscount": { "$ref": "#/components/schemas/Price" },
|
||||
"deliveries": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/components/schemas/CartDelivery" }
|
||||
@@ -542,51 +964,75 @@
|
||||
"processing": { "type": "boolean" },
|
||||
"paymentInProgress": { "type": "boolean" },
|
||||
"orderReference": { "type": "string" },
|
||||
"paymentStatus": { "type": "string" }
|
||||
"paymentStatus": { "type": "string" },
|
||||
"vouchers": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/components/schemas/Voucher" }
|
||||
},
|
||||
"subscriptionDetails": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/SubscriptionDetails"
|
||||
}
|
||||
},
|
||||
"userId": { "type": "string" },
|
||||
"confirmation": { "$ref": "#/components/schemas/ConfirmationStatus" },
|
||||
"checkoutOrderId": { "type": "string" },
|
||||
"checkoutStatus": { "type": "string" },
|
||||
"checkoutCountry": { "type": "string" },
|
||||
"paymentDeclinedNotices": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/components/schemas/Notice" }
|
||||
}
|
||||
},
|
||||
"required": ["id", "items", "totalPrice", "totalTax", "totalDiscount"]
|
||||
},
|
||||
"CartItem": {
|
||||
"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": {
|
||||
"id": { "type": "integer" },
|
||||
"itemId": { "type": "integer" },
|
||||
"parentId": { "type": "integer" },
|
||||
"parentId": {
|
||||
"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" },
|
||||
"name": { "type": "string" },
|
||||
"price": { "type": "integer", "format": "int64" },
|
||||
"totalPrice": { "type": "integer", "format": "int64" },
|
||||
"totalTax": { "type": "integer", "format": "int64" },
|
||||
"orgPrice": { "type": "integer", "format": "int64" },
|
||||
"price": { "$ref": "#/components/schemas/Price" },
|
||||
"totalPrice": { "$ref": "#/components/schemas/Price" },
|
||||
"orgPrice": { "$ref": "#/components/schemas/Price" },
|
||||
"stock": {
|
||||
"type": "integer",
|
||||
"description": "0=OutOfStock,1=LowStock,2=InStock"
|
||||
},
|
||||
"qty": { "type": "integer" },
|
||||
"tax": { "type": "integer" },
|
||||
"taxRate": { "type": "integer" },
|
||||
"brand": { "type": "string" },
|
||||
"category": { "type": "string" },
|
||||
"category2": { "type": "string" },
|
||||
"category3": { "type": "string" },
|
||||
"category4": { "type": "string" },
|
||||
"category5": { "type": "string" },
|
||||
"discount": { "$ref": "#/components/schemas/Price" },
|
||||
"disclaimer": { "type": "string" },
|
||||
"sellerId": { "type": "string" },
|
||||
"sellerName": { "type": "string" },
|
||||
"type": { "type": "string", "description": "Article type" },
|
||||
"image": { "type": "string" },
|
||||
"outlet": { "type": "string", "nullable": true },
|
||||
"storeId": { "type": "string", "nullable": true }
|
||||
"storeId": { "type": "string", "nullable": true },
|
||||
"meta": { "$ref": "#/components/schemas/ItemMeta" },
|
||||
"saleStatus": { "type": "string" },
|
||||
"marking": { "$ref": "#/components/schemas/Marking" },
|
||||
"customFields": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" },
|
||||
"description": "User-supplied custom input fields for this line"
|
||||
},
|
||||
"subscriptionDetailsId": { "type": "string" },
|
||||
"orderReference": { "type": "string" },
|
||||
"isSubscribed": { "type": "boolean" }
|
||||
},
|
||||
"required": ["id", "sku", "name", "price", "qty", "tax"]
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"provider": { "type": "string" },
|
||||
"price": { "type": "integer", "format": "int64" },
|
||||
"price": { "$ref": "#/components/schemas/Price" },
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": { "type": "integer" }
|
||||
@@ -621,7 +1067,17 @@
|
||||
"type": "string",
|
||||
"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"]
|
||||
},
|
||||
@@ -642,7 +1098,17 @@
|
||||
"properties": {
|
||||
"sku": { "type": "string" },
|
||||
"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"]
|
||||
},
|
||||
@@ -670,6 +1136,99 @@
|
||||
"pickupPoint": { "$ref": "#/components/schemas/PickupPoint" }
|
||||
},
|
||||
"required": ["provider", "items"]
|
||||
},
|
||||
"AddVoucherRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": { "type": "string" }
|
||||
},
|
||||
"required": ["code"]
|
||||
},
|
||||
"UpsertSubscriptionDetails": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"offeringCode": { "type": "string" },
|
||||
"signingType": { "type": "string" },
|
||||
"data": { "type": "object" }
|
||||
},
|
||||
"required": ["offeringCode", "signingType"]
|
||||
},
|
||||
"Voucher": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": { "type": "string" },
|
||||
"applied": { "type": "boolean" },
|
||||
"rules": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"description": { "type": "string" },
|
||||
"id": { "type": "integer", "format": "int64" },
|
||||
"value": { "type": "integer", "format": "int64" }
|
||||
},
|
||||
"required": ["code", "applied", "rules", "id", "value"]
|
||||
},
|
||||
"SubscriptionDetails": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"offeringCode": { "type": "string" },
|
||||
"signingType": { "type": "string" },
|
||||
"data": { "type": "object" }
|
||||
},
|
||||
"required": ["id"]
|
||||
},
|
||||
"Marking": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": { "type": "integer" },
|
||||
"text": { "type": "string" }
|
||||
},
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"timestamp": { "type": "string", "format": "date-time" },
|
||||
"message": { "type": "string" },
|
||||
"code": { "type": "string", "nullable": true }
|
||||
},
|
||||
"required": ["timestamp", "message"]
|
||||
},
|
||||
"SetUserIdRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"userId": { "type": "string" }
|
||||
},
|
||||
"required": ["userId"]
|
||||
},
|
||||
"LineItemMarkingRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": { "type": "integer" },
|
||||
"marking": { "type": "string" }
|
||||
},
|
||||
"required": ["type", "marking"]
|
||||
},
|
||||
"CreateCheckoutOrderRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"terms": { "type": "string" },
|
||||
"country": { "type": "string" }
|
||||
},
|
||||
"required": ["terms", "country"]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||
"go.opentelemetry.io/otel/log/global"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/sdk/log"
|
||||
"go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/trace"
|
||||
)
|
||||
|
||||
// setupOTelSDK bootstraps the OpenTelemetry pipeline.
|
||||
// If it does not return an error, make sure to call shutdown for proper cleanup.
|
||||
func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
|
||||
var shutdownFuncs []func(context.Context) error
|
||||
var err error
|
||||
|
||||
// shutdown calls cleanup functions registered via shutdownFuncs.
|
||||
// The errors from the calls are joined.
|
||||
// Each registered cleanup will be invoked once.
|
||||
shutdown := func(ctx context.Context) error {
|
||||
var err error
|
||||
for _, fn := range shutdownFuncs {
|
||||
err = errors.Join(err, fn(ctx))
|
||||
}
|
||||
shutdownFuncs = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// handleErr calls shutdown for cleanup and makes sure that all errors are returned.
|
||||
handleErr := func(inErr error) {
|
||||
err = errors.Join(inErr, shutdown(ctx))
|
||||
}
|
||||
|
||||
// Set up propagator.
|
||||
prop := newPropagator()
|
||||
otel.SetTextMapPropagator(prop)
|
||||
|
||||
// Set up trace provider.
|
||||
tracerProvider, err := newTracerProvider()
|
||||
if err != nil {
|
||||
handleErr(err)
|
||||
return shutdown, err
|
||||
}
|
||||
shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown)
|
||||
otel.SetTracerProvider(tracerProvider)
|
||||
|
||||
// Set up meter provider.
|
||||
meterProvider, err := newMeterProvider()
|
||||
if err != nil {
|
||||
handleErr(err)
|
||||
return shutdown, err
|
||||
}
|
||||
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
|
||||
otel.SetMeterProvider(meterProvider)
|
||||
|
||||
// Set up logger provider.
|
||||
loggerProvider, err := newLoggerProvider()
|
||||
if err != nil {
|
||||
handleErr(err)
|
||||
return shutdown, err
|
||||
}
|
||||
shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown)
|
||||
global.SetLoggerProvider(loggerProvider)
|
||||
|
||||
return shutdown, err
|
||||
}
|
||||
|
||||
func newPropagator() propagation.TextMapPropagator {
|
||||
return propagation.NewCompositeTextMapPropagator(
|
||||
propagation.TraceContext{},
|
||||
propagation.Baggage{},
|
||||
)
|
||||
}
|
||||
|
||||
func newTracerProvider() (*trace.TracerProvider, error) {
|
||||
traceExporter, err := otlptracegrpc.New(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tracerProvider := trace.NewTracerProvider(
|
||||
trace.WithBatcher(traceExporter,
|
||||
// Default is 5s. Set to 1s for demonstrative purposes.
|
||||
trace.WithBatchTimeout(time.Second)),
|
||||
)
|
||||
return tracerProvider, nil
|
||||
}
|
||||
|
||||
func newMeterProvider() (*metric.MeterProvider, error) {
|
||||
exporter, err := otlpmetricgrpc.New(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
provider := metric.NewMeterProvider(metric.WithReader(metric.NewPeriodicReader(exporter)))
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func newLoggerProvider() (*log.LoggerProvider, error) {
|
||||
logExporter, err := otlploggrpc.New(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
loggerProvider := log.NewLoggerProvider(
|
||||
log.WithProcessor(log.NewBatchProcessor(logExporter)),
|
||||
)
|
||||
return loggerProvider, nil
|
||||
}
|
||||
+445
-302
@@ -1,41 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
"git.tornberg.me/go-cart-actor/pkg/voucher"
|
||||
"github.com/gogo/protobuf/proto"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/anypb"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
var (
|
||||
grainMutations = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "cart_grain_mutations_total",
|
||||
Help: "The total number of mutations",
|
||||
})
|
||||
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "cart_grain_lookups_total",
|
||||
Help: "The total number of lookups",
|
||||
})
|
||||
)
|
||||
|
||||
type PoolServer struct {
|
||||
actor.GrainPool[*CartGrain]
|
||||
pod_name string
|
||||
klarnaClient *KlarnaClient
|
||||
actor.GrainPool[cart.CartGrain]
|
||||
pod_name string
|
||||
}
|
||||
|
||||
func NewPoolServer(pool actor.GrainPool[*CartGrain], pod_name string, klarnaClient *KlarnaClient) *PoolServer {
|
||||
return &PoolServer{
|
||||
GrainPool: pool,
|
||||
pod_name: pod_name,
|
||||
klarnaClient: klarnaClient,
|
||||
func NewPoolServer(pool actor.GrainPool[cart.CartGrain], pod_name string) *PoolServer {
|
||||
srv := &PoolServer{
|
||||
GrainPool: pool,
|
||||
pod_name: pod_name,
|
||||
}
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
func (s *PoolServer) ApplyLocal(id CartId, mutation ...proto.Message) (*actor.MutationResult[*CartGrain], error) {
|
||||
return s.Apply(uint64(id), mutation...)
|
||||
func (s *PoolServer) ApplyLocal(ctx context.Context, id cart.CartId, mutation ...proto.Message) (*actor.MutationResult[cart.CartGrain], error) {
|
||||
return s.Apply(ctx, uint64(id), mutation...)
|
||||
}
|
||||
|
||||
func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
grain, err := s.Get(uint64(id))
|
||||
func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
grain, err := s.Get(r.Context(), uint64(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -43,16 +66,18 @@ func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id C
|
||||
return s.WriteResult(w, grain)
|
||||
}
|
||||
|
||||
func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
sku := r.PathValue("sku")
|
||||
msg, err := GetItemAddMessage(sku, 1, getCountryFromHost(r.Host), nil)
|
||||
msg, err := GetItemAddMessage(r.Context(), sku, 1, getCountryFromHost(r.Host), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := s.ApplyLocal(id, msg)
|
||||
|
||||
data, err := s.ApplyLocal(r.Context(), id, msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
grainMutations.Add(float64(len(data.Mutations)))
|
||||
return s.WriteResult(w, data)
|
||||
}
|
||||
|
||||
@@ -73,92 +98,27 @@ func (s *PoolServer) WriteResult(w http.ResponseWriter, result any) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PoolServer) DeleteItemHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
func (s *PoolServer) DeleteItemHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
|
||||
itemIdString := r.PathValue("itemId")
|
||||
itemId, err := strconv.ParseInt(itemIdString, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := s.ApplyLocal(id, &messages.RemoveItem{Id: uint32(itemId)})
|
||||
data, err := s.ApplyLocal(r.Context(), id, &messages.RemoveItem{Id: uint32(itemId)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, data)
|
||||
}
|
||||
|
||||
type SetDeliveryRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
Items []uint32 `json:"items"`
|
||||
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
|
||||
}
|
||||
|
||||
func (s *PoolServer) SetDeliveryHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
|
||||
delivery := SetDeliveryRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(&delivery)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := s.ApplyLocal(id, &messages.SetDelivery{
|
||||
Provider: delivery.Provider,
|
||||
Items: delivery.Items,
|
||||
PickupPoint: delivery.PickupPoint,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, data)
|
||||
}
|
||||
|
||||
func (s *PoolServer) SetPickupPointHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
|
||||
deliveryIdString := r.PathValue("deliveryId")
|
||||
deliveryId, err := strconv.ParseInt(deliveryIdString, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pickupPoint := messages.PickupPoint{}
|
||||
err = json.NewDecoder(r.Body).Decode(&pickupPoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.ApplyLocal(id, &messages.SetPickupPoint{
|
||||
DeliveryId: uint32(deliveryId),
|
||||
Id: pickupPoint.Id,
|
||||
Name: pickupPoint.Name,
|
||||
Address: pickupPoint.Address,
|
||||
City: pickupPoint.City,
|
||||
Zip: pickupPoint.Zip,
|
||||
Country: pickupPoint.Country,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
func (s *PoolServer) RemoveDeliveryHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
|
||||
deliveryIdString := r.PathValue("deliveryId")
|
||||
deliveryId, err := strconv.Atoi(deliveryIdString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.ApplyLocal(id, &messages.RemoveDelivery{Id: uint32(deliveryId)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
func (s *PoolServer) QuantityChangeHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
func (s *PoolServer) QuantityChangeHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
changeQuantity := messages.ChangeQuantity{}
|
||||
err := json.NewDecoder(r.Body).Decode(&changeQuantity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.ApplyLocal(id, &changeQuantity)
|
||||
reply, err := s.ApplyLocal(r.Context(), id, &changeQuantity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -169,6 +129,12 @@ type Item struct {
|
||||
Sku string `json:"sku"`
|
||||
Quantity int `json:"quantity"`
|
||||
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 {
|
||||
@@ -176,56 +142,163 @@ type SetCartItems struct {
|
||||
Items []Item `json:"items"`
|
||||
}
|
||||
|
||||
func getMultipleAddMessages(items []Item, country string) []proto.Message {
|
||||
wg := sync.WaitGroup{}
|
||||
mu := sync.Mutex{}
|
||||
msgs := make([]proto.Message, 0, len(items))
|
||||
for _, itm := range items {
|
||||
wg.Go(
|
||||
func() {
|
||||
msg, err := GetItemAddMessage(itm.Sku, itm.Quantity, country, itm.StoreId)
|
||||
if err != nil {
|
||||
log.Printf("error adding item %s: %v", itm.Sku, err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
msgs = append(msgs, msg)
|
||||
mu.Unlock()
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
return msgs
|
||||
// itemGroup is a top-level item together with its child sub-articles. The child
|
||||
// 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
|
||||
}
|
||||
|
||||
func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
// 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{}
|
||||
for i, itm := range items {
|
||||
wg.Go(func() {
|
||||
parentMsg, parentProduct, err := BuildItemMessage(ctx, itm.Sku, itm.Quantity, country, itm.StoreId, nil)
|
||||
if err != nil {
|
||||
log.Printf("error adding item %s: %v", itm.Sku, err)
|
||||
return
|
||||
}
|
||||
parentMsg.CustomFields = itm.CustomFields
|
||||
groups[i].parent = parentMsg
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
return groups
|
||||
}
|
||||
|
||||
// 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 {
|
||||
setCartItems := SetCartItems{}
|
||||
err := json.NewDecoder(r.Body).Decode(&setCartItems)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msgs := make([]proto.Message, 0, len(setCartItems.Items)+1)
|
||||
msgs = append(msgs, &messages.ClearCartRequest{})
|
||||
msgs = append(msgs, getMultipleAddMessages(setCartItems.Items, setCartItems.Country)...)
|
||||
|
||||
reply, err := s.ApplyLocal(id, msgs...)
|
||||
if _, err := s.ApplyLocal(r.Context(), id, &messages.ClearCartRequest{}); err != nil {
|
||||
return err
|
||||
}
|
||||
groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country)
|
||||
reply, err := s.applyItemGroups(r.Context(), id, groups)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
func (s *PoolServer) AddMultipleItemHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
func (s *PoolServer) AddMultipleItemHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
setCartItems := SetCartItems{}
|
||||
err := json.NewDecoder(r.Body).Decode(&setCartItems)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reply, err := s.ApplyLocal(id, getMultipleAddMessages(setCartItems.Items, setCartItems.Country)...)
|
||||
groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country)
|
||||
reply, err := s.applyItemGroups(r.Context(), id, groups)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -234,23 +307,38 @@ type AddRequest struct {
|
||||
Quantity int32 `json:"quantity"`
|
||||
Country string `json:"country"`
|
||||
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 CartId) error {
|
||||
func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
addRequest := AddRequest{Quantity: 1}
|
||||
err := json.NewDecoder(r.Body).Decode(&addRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg, err := GetItemAddMessage(addRequest.Sku, int(addRequest.Quantity), addRequest.Country, addRequest.StoreId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.ApplyLocal(id, msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -273,61 +361,6 @@ func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request
|
||||
// return json.NewEncoder(w).Encode(order)
|
||||
// }
|
||||
|
||||
func getCurrency(country string) string {
|
||||
if country == "no" {
|
||||
return "NOK"
|
||||
}
|
||||
return "SEK"
|
||||
}
|
||||
|
||||
func getLocale(country string) string {
|
||||
if country == "no" {
|
||||
return "nb-no"
|
||||
}
|
||||
return "sv-se"
|
||||
}
|
||||
|
||||
func (s *PoolServer) CreateOrUpdateCheckout(host string, id CartId) (*CheckoutOrder, error) {
|
||||
country := getCountryFromHost(host)
|
||||
meta := &CheckoutMeta{
|
||||
Terms: fmt.Sprintf("https://%s/terms", host),
|
||||
Checkout: fmt.Sprintf("https://%s/checkout?order_id={checkout.order.id}", host),
|
||||
Confirmation: fmt.Sprintf("https://%s/confirmation/{checkout.order.id}", host),
|
||||
Validation: fmt.Sprintf("https://%s/validate", host),
|
||||
Push: fmt.Sprintf("https://%s/push?order_id={checkout.order.id}", host),
|
||||
Country: country,
|
||||
Currency: getCurrency(country),
|
||||
Locale: getLocale(country),
|
||||
}
|
||||
|
||||
// Get current grain state (may be local or remote)
|
||||
grain, err := s.Get(uint64(id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build pure checkout payload
|
||||
payload, _, err := BuildCheckoutOrderPayload(grain, meta)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if grain.OrderReference != "" {
|
||||
return s.klarnaClient.UpdateOrder(grain.OrderReference, bytes.NewReader(payload))
|
||||
} else {
|
||||
return s.klarnaClient.CreateOrder(bytes.NewReader(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PoolServer) ApplyCheckoutStarted(klarnaOrder *CheckoutOrder, id CartId) (*actor.MutationResult[*CartGrain], error) {
|
||||
// Persist initialization state via mutation (best-effort)
|
||||
return s.ApplyLocal(id, &messages.InitializeCheckout{
|
||||
OrderId: klarnaOrder.ID,
|
||||
Status: klarnaOrder.Status,
|
||||
PaymentInProgress: true,
|
||||
})
|
||||
}
|
||||
|
||||
// func (s *PoolServer) HandleCheckout(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
// klarnaOrder, err := s.CreateOrUpdateCheckout(r.Host, id)
|
||||
// if err != nil {
|
||||
@@ -341,54 +374,9 @@ func (s *PoolServer) ApplyCheckoutStarted(klarnaOrder *CheckoutOrder, id CartId)
|
||||
// }
|
||||
//
|
||||
|
||||
func CookieCartIdHandler(fn func(cartId CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var id CartId
|
||||
cookie, err := r.Cookie("cartid")
|
||||
if err != nil || cookie.Value == "" {
|
||||
id = MustNewCartId()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "cartid",
|
||||
Value: id.String(),
|
||||
Secure: r.TLS != nil,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
Expires: time.Now().AddDate(0, 0, 14),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
w.Header().Set("Set-Cart-Id", id.String())
|
||||
} else {
|
||||
parsed, ok := ParseCartId(cookie.Value)
|
||||
if !ok {
|
||||
id = MustNewCartId()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "cartid",
|
||||
Value: id.String(),
|
||||
Secure: r.TLS != nil,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
Expires: time.Now().AddDate(0, 0, 14),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
w.Header().Set("Set-Cart-Id", id.String())
|
||||
} else {
|
||||
id = parsed
|
||||
}
|
||||
}
|
||||
|
||||
err = fn(id, w, r)
|
||||
if err != nil {
|
||||
log.Printf("Server error, not remote error: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// Removed leftover legacy block after CookieCartIdHandler (obsolete code referencing cid/legacy)
|
||||
|
||||
func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, cartId CartId) error {
|
||||
func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||
// Clear cart cookie (breaking change: do not issue a new legacy id here)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "cartid",
|
||||
@@ -403,53 +391,54 @@ func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, ca
|
||||
return nil
|
||||
}
|
||||
|
||||
func CartIdHandler(fn func(cartId CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var id CartId
|
||||
raw := r.PathValue("id")
|
||||
// If no id supplied, generate a new one
|
||||
if raw == "" {
|
||||
id := MustNewCartId()
|
||||
w.Header().Set("Set-Cart-Id", id.String())
|
||||
} else {
|
||||
// Parse base62 cart id
|
||||
if parsedId, ok := ParseCartId(raw); !ok {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("cart id is invalid"))
|
||||
return
|
||||
} else {
|
||||
id = parsedId
|
||||
}
|
||||
}
|
||||
|
||||
err := fn(id, w, r)
|
||||
if err != nil {
|
||||
log.Printf("Server error, not remote error: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request, cartId CartId) error) func(cartId CartId, w http.ResponseWriter, r *http.Request) error {
|
||||
return func(cartId CartId, w http.ResponseWriter, r *http.Request) error {
|
||||
func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error) func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error {
|
||||
return func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error {
|
||||
if ownerHost, ok := s.OwnerHost(uint64(cartId)); ok {
|
||||
handled, err := ownerHost.Proxy(uint64(cartId), w, r)
|
||||
ctx, span := tracer.Start(r.Context(), "proxy")
|
||||
defer span.End()
|
||||
span.SetAttributes(attribute.String("cartid", cartId.String()))
|
||||
hostAttr := attribute.String("other host", ownerHost.Name())
|
||||
span.SetAttributes(hostAttr)
|
||||
logger.InfoContext(ctx, "cart proxyed", "result", ownerHost.Name())
|
||||
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
|
||||
handled, err := ownerHost.Proxy(uint64(cartId), w, r, nil)
|
||||
|
||||
grainLookups.Inc()
|
||||
if err == nil && handled {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
_, span := tracer.Start(r.Context(), "own")
|
||||
span.SetAttributes(attribute.String("cartid", cartId.String()))
|
||||
defer span.End()
|
||||
return fn(w, r, cartId)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
tracer = otel.Tracer(name)
|
||||
meter = otel.Meter(name)
|
||||
logger = otelslog.NewLogger(name)
|
||||
proxyCalls metric.Int64Counter
|
||||
)
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
proxyCalls, err = meter.Int64Counter("proxy.calls",
|
||||
metric.WithDescription("Number of proxy calls"),
|
||||
metric.WithUnit("{calls}"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
type AddVoucherRequest struct {
|
||||
VoucherCode string `json:"code"`
|
||||
}
|
||||
|
||||
func (s *PoolServer) AddVoucherHandler(w http.ResponseWriter, r *http.Request, cartId CartId) error {
|
||||
func (s *PoolServer) AddVoucherHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||
data := &AddVoucherRequest{}
|
||||
json.NewDecoder(r.Body).Decode(data)
|
||||
v := voucher.Service{}
|
||||
@@ -459,7 +448,7 @@ func (s *PoolServer) AddVoucherHandler(w http.ResponseWriter, r *http.Request, c
|
||||
w.Write([]byte(err.Error()))
|
||||
return err
|
||||
}
|
||||
reply, err := s.ApplyLocal(cartId, msg)
|
||||
reply, err := s.ApplyLocal(r.Context(), cartId, msg)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
@@ -469,7 +458,41 @@ func (s *PoolServer) AddVoucherHandler(w http.ResponseWriter, r *http.Request, c
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request, cartId CartId) error {
|
||||
type SubscriptionDetailsRequest struct {
|
||||
Id *string `json:"id,omitempty"`
|
||||
OfferingCode string `json:"offeringCode,omitempty"`
|
||||
SigningType string `json:"signingType,omitempty"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func (sd *SubscriptionDetailsRequest) ToMessage() *messages.UpsertSubscriptionDetails {
|
||||
return &messages.UpsertSubscriptionDetails{
|
||||
Id: sd.Id,
|
||||
OfferingCode: sd.OfferingCode,
|
||||
SigningType: sd.SigningType,
|
||||
Data: &anypb.Any{Value: sd.Data},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PoolServer) SubscriptionDetailsHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||
data := &SubscriptionDetailsRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(data)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return err
|
||||
}
|
||||
reply, err := s.ApplyLocal(r.Context(), cartId, data.ToMessage())
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return err
|
||||
}
|
||||
s.WriteResult(w, reply)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||
|
||||
idStr := r.PathValue("voucherId")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
@@ -478,7 +501,7 @@ func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request
|
||||
w.Write([]byte(err.Error()))
|
||||
return err
|
||||
}
|
||||
reply, err := s.ApplyLocal(cartId, &messages.RemoveVoucher{Id: uint32(id)})
|
||||
reply, err := s.ApplyLocal(r.Context(), cartId, &messages.RemoveVoucher{Id: uint32(id)})
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
@@ -488,45 +511,165 @@ func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PoolServer) Serve() *http.ServeMux {
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("OPTIONS /", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
func (s *PoolServer) SetUserIdHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||
setUserId := messages.SetUserId{}
|
||||
err := json.NewDecoder(r.Body).Decode(&setUserId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.ApplyLocal(r.Context(), cartId, &setUserId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
func (s *PoolServer) LineItemMarkingHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||
itemIdStr := r.PathValue("itemId")
|
||||
itemId, err := strconv.ParseInt(itemIdStr, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lineItemMarking := messages.LineItemMarking{Id: uint32(itemId)}
|
||||
err = json.NewDecoder(r.Body).Decode(&lineItemMarking)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.ApplyLocal(r.Context(), cartId, &lineItemMarking)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
func (s *PoolServer) RemoveLineItemMarkingHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||
itemIdStr := r.PathValue("itemId")
|
||||
itemId, err := strconv.ParseInt(itemIdStr, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
removeLineItemMarking := messages.RemoveLineItemMarking{Id: uint32(itemId)}
|
||||
reply, err := s.ApplyLocal(r.Context(), cartId, &removeLineItemMarking)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return nil
|
||||
}
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mutation := &messages.Mutation{}
|
||||
err = proto.Unmarshal(data, mutation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.ApplyLocal(r.Context(), cartId, mutation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
func (s *PoolServer) GetAnywhere(ctx context.Context, cartId cart.CartId) (*cart.CartGrain, error) {
|
||||
id := uint64(cartId)
|
||||
if host, isOnOtherHost := s.OwnerHost(id); isOnOtherHost {
|
||||
return host.Get(ctx, id)
|
||||
}
|
||||
return s.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (s *PoolServer) ApplyAnywhere(ctx context.Context, cartId cart.CartId, msgs ...proto.Message) error {
|
||||
id := uint64(cartId)
|
||||
if host, isOnOtherHost := s.OwnerHost(id); isOnOtherHost {
|
||||
_, err := host.Apply(ctx, id, msgs...)
|
||||
return err
|
||||
}
|
||||
_, err := s.Apply(ctx, id, msgs...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PoolServer) Serve(mux *http.ServeMux) {
|
||||
|
||||
// mux.HandleFunc("OPTIONS /cart", func(w http.ResponseWriter, r *http.Request) {
|
||||
// w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
// w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE")
|
||||
// w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
// w.WriteHeader(http.StatusOK)
|
||||
// })
|
||||
|
||||
handleFunc := func(pattern string, handlerFunc func(http.ResponseWriter, *http.Request)) {
|
||||
attr := attribute.String("http.route", pattern)
|
||||
mux.HandleFunc(pattern, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
span := trace.SpanFromContext(r.Context())
|
||||
span.SetName(pattern)
|
||||
span.SetAttributes(attr)
|
||||
|
||||
labeler, _ := otelhttp.LabelerFromContext(r.Context())
|
||||
labeler.Add(attr)
|
||||
|
||||
handlerFunc(w, r)
|
||||
}))
|
||||
}
|
||||
|
||||
handleFunc("GET /cart", CookieCartIdHandler(s.ProxyHandler(s.GetCartHandler)))
|
||||
handleFunc("GET /cart/add/{sku}", CookieCartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler)))
|
||||
handleFunc("POST /cart/add", CookieCartIdHandler(s.ProxyHandler(s.AddMultipleItemHandler)))
|
||||
handleFunc("POST /cart", CookieCartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
|
||||
handleFunc("POST /cart/set", CookieCartIdHandler(s.ProxyHandler(s.SetCartItemsHandler)))
|
||||
handleFunc("DELETE /cart/{itemId}", CookieCartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
|
||||
handleFunc("PUT /cart", CookieCartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
|
||||
handleFunc("DELETE /cart", CookieCartIdHandler(s.ProxyHandler(s.RemoveCartCookie)))
|
||||
handleFunc("PUT /cart/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
|
||||
handleFunc("PUT /cart/subscription-details", CookieCartIdHandler(s.ProxyHandler(s.SubscriptionDetailsHandler)))
|
||||
handleFunc("DELETE /cart/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
|
||||
handleFunc("PUT /cart/user", CookieCartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
|
||||
|
||||
handleFunc("PUT /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
|
||||
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/confirmation/{orderId}", CookieCartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
|
||||
|
||||
handleFunc("GET /cart/byid/{id}", CartIdHandler(s.ProxyHandler(s.GetCartHandler)))
|
||||
|
||||
mux.HandleFunc("GET /", CookieCartIdHandler(s.ProxyHandler(s.GetCartHandler)))
|
||||
mux.HandleFunc("GET /add/{sku}", CookieCartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler)))
|
||||
mux.HandleFunc("POST /add", CookieCartIdHandler(s.ProxyHandler(s.AddMultipleItemHandler)))
|
||||
mux.HandleFunc("POST /", CookieCartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
|
||||
mux.HandleFunc("POST /set", CookieCartIdHandler(s.ProxyHandler(s.SetCartItemsHandler)))
|
||||
mux.HandleFunc("DELETE /{itemId}", CookieCartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
|
||||
mux.HandleFunc("PUT /", CookieCartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
|
||||
mux.HandleFunc("DELETE /", CookieCartIdHandler(s.ProxyHandler(s.RemoveCartCookie)))
|
||||
mux.HandleFunc("POST /delivery", CookieCartIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
|
||||
mux.HandleFunc("DELETE /delivery/{deliveryId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
|
||||
mux.HandleFunc("PUT /delivery/{deliveryId}/pickupPoint", CookieCartIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
|
||||
mux.HandleFunc("PUT /voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
|
||||
mux.HandleFunc("DELETE /voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
|
||||
|
||||
//mux.HandleFunc("GET /checkout", CookieCartIdHandler(s.ProxyHandler(s.HandleCheckout)))
|
||||
//mux.HandleFunc("GET /confirmation/{orderId}", CookieCartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
|
||||
|
||||
mux.HandleFunc("GET /byid/{id}", CartIdHandler(s.ProxyHandler(s.GetCartHandler)))
|
||||
mux.HandleFunc("GET /byid/{id}/add/{sku}", CartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler)))
|
||||
mux.HandleFunc("POST /byid/{id}", CartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
|
||||
mux.HandleFunc("DELETE /byid/{id}/{itemId}", CartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
|
||||
mux.HandleFunc("PUT /byid/{id}", CartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
|
||||
mux.HandleFunc("POST /byid/{id}/delivery", CartIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
|
||||
mux.HandleFunc("DELETE /byid/{id}/delivery/{deliveryId}", CartIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
|
||||
mux.HandleFunc("PUT /byid/{id}/delivery/{deliveryId}/pickupPoint", CartIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
|
||||
mux.HandleFunc("PUT /byid/{id}/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
|
||||
mux.HandleFunc("DELETE /byid/{id}/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
|
||||
//mux.HandleFunc("GET /byid/{id}/checkout", CartIdHandler(s.ProxyHandler(s.HandleCheckout)))
|
||||
//mux.HandleFunc("GET /byid/{id}/confirmation", CartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
|
||||
|
||||
return mux
|
||||
handleFunc("POST /cart/byid/{id}", CartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
|
||||
handleFunc("DELETE /cart/byid/{id}/{itemId}", CartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
|
||||
handleFunc("PUT /cart/byid/{id}", CartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
|
||||
handleFunc("PUT /cart/byid/{id}/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
|
||||
handleFunc("DELETE /cart/byid/{id}/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
|
||||
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("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)))
|
||||
}
|
||||
|
||||
+188
-90
@@ -1,121 +1,219 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
"github.com/matst80/slask-finder/pkg/index"
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||
)
|
||||
|
||||
// TODO make this configurable
|
||||
func getBaseUrl(country string) string {
|
||||
// if country == "se" {
|
||||
// return "http://s10n-se:8080"
|
||||
// }
|
||||
if country == "no" {
|
||||
return "http://s10n-no.s10n:8080"
|
||||
}
|
||||
if country == "se" {
|
||||
return "http://s10n-se.s10n:8080"
|
||||
}
|
||||
return "http://localhost:8082"
|
||||
// consumedKeys are product-document keys that are mapped to typed AddItem
|
||||
// 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",
|
||||
}
|
||||
|
||||
func FetchItem(sku string, country string) (*index.DataItem, error) {
|
||||
// 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 {
|
||||
return getEnv("PRODUCT_BASE_URL", "http://localhost:8082")
|
||||
}
|
||||
|
||||
// ProductItem is the flat product document served by GET /api/get/{sku}.
|
||||
// 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)
|
||||
res, err := http.Get(fmt.Sprintf("%s/api/by-sku/%s", baseUrl, sku))
|
||||
innerCtx, span := tracer.Start(ctx, fmt.Sprintf("fetching data for %s", sku))
|
||||
defer span.End()
|
||||
req, err := http.NewRequestWithContext(innerCtx, http.MethodGet, fmt.Sprintf("%s/api/get/%s", baseUrl, sku), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var item index.DataItem
|
||||
err = json.NewDecoder(res.Body).Decode(&item)
|
||||
return &item, err
|
||||
}
|
||||
|
||||
func GetItemAddMessage(sku string, qty int, country string, storeId *string) (*messages.AddItem, error) {
|
||||
item, err := FetchItem(sku, country)
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("product service returned %d for sku %s", res.StatusCode, sku)
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ToItemAddMessage(item, storeId, qty, country), nil
|
||||
|
||||
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 ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country string) *messages.AddItem {
|
||||
orgPrice, _ := getInt(item.GetNumberFieldValue(5)) // getInt(item.Fields[5])
|
||||
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
|
||||
}
|
||||
|
||||
price, err := getInt(item.GetNumberFieldValue(4)) //Fields[4]
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil
|
||||
return nil, nil, err
|
||||
}
|
||||
msg, err := ToItemAddMessage(item, parent, storeId, qty, country)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return msg, item, nil
|
||||
}
|
||||
|
||||
stock := StockStatus(0)
|
||||
centralStockValue, ok := item.GetStringFieldValue(3)
|
||||
// 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 ok {
|
||||
pureNumber := strings.Replace(centralStockValue, "+", "", -1)
|
||||
if centralStock, err := strconv.ParseInt(pureNumber, 10, 64); err == nil {
|
||||
stock = StockStatus(centralStock)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
storeStock, ok := item.Stock.GetStock()[*storeId]
|
||||
if ok {
|
||||
stock = StockStatus(storeStock)
|
||||
stock = item.InStock
|
||||
}
|
||||
|
||||
// Top-level items price from their own product; children are priced from the
|
||||
// parent product via the accessory-price hook.
|
||||
price := int64(item.Price)
|
||||
if parent != nil {
|
||||
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
|
||||
}
|
||||
|
||||
articleType, _ := item.GetStringFieldValue(1) //.Fields[1].(string)
|
||||
outletGrade, ok := item.GetStringFieldValue(20) //.Fields[20].(string)
|
||||
var outlet *string
|
||||
if ok {
|
||||
outlet = &outletGrade
|
||||
}
|
||||
sellerId, _ := item.GetStringFieldValue(24) // .Fields[24].(string)
|
||||
sellerName, _ := item.GetStringFieldValue(9) // .Fields[9].(string)
|
||||
|
||||
brand, _ := item.GetStringFieldValue(2) //.Fields[2].(string)
|
||||
category, _ := item.GetStringFieldValue(10) //.Fields[10].(string)
|
||||
category2, _ := item.GetStringFieldValue(11) //.Fields[11].(string)
|
||||
category3, _ := item.GetStringFieldValue(12) //.Fields[12].(string)
|
||||
category4, _ := item.GetStringFieldValue(13) //Fields[13].(string)
|
||||
category5, _ := item.GetStringFieldValue(14) //.Fields[14].(string)
|
||||
|
||||
return &messages.AddItem{
|
||||
ItemId: uint32(item.Id),
|
||||
Quantity: int32(qty),
|
||||
Price: int64(price),
|
||||
OrgPrice: int64(orgPrice),
|
||||
Sku: item.GetSku(),
|
||||
Name: item.Title,
|
||||
Image: item.Img,
|
||||
Stock: int32(stock),
|
||||
Brand: brand,
|
||||
Category: category,
|
||||
Category2: category2,
|
||||
Category3: category3,
|
||||
Category4: category4,
|
||||
Category5: category5,
|
||||
Tax: getTax(articleType),
|
||||
SellerId: sellerId,
|
||||
SellerName: sellerName,
|
||||
ArticleType: articleType,
|
||||
Disclaimer: item.Disclaimer,
|
||||
Country: country,
|
||||
Outlet: outlet,
|
||||
StoreId: storeId,
|
||||
}
|
||||
}
|
||||
|
||||
func getTax(articleType string) int32 {
|
||||
switch articleType {
|
||||
case "ZDIE":
|
||||
return 600
|
||||
default:
|
||||
return 2500
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
//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)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||
)
|
||||
|
||||
func getCurrency(country string) string {
|
||||
if country == "no" {
|
||||
return "NOK"
|
||||
}
|
||||
return "SEK"
|
||||
}
|
||||
|
||||
func getLocale(country string) string {
|
||||
if country == "no" {
|
||||
return "nb-no"
|
||||
}
|
||||
return "sv-se"
|
||||
}
|
||||
|
||||
func getLocationId(item *cart.CartItem) inventory.LocationID {
|
||||
if item.StoreId == nil || *item.StoreId == "" {
|
||||
return "se"
|
||||
}
|
||||
return inventory.LocationID(*item.StoreId)
|
||||
}
|
||||
|
||||
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
|
||||
var requests []inventory.ReserveRequest
|
||||
for _, item := range items {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
requests = append(requests, inventory.ReserveRequest{
|
||||
InventoryReference: &inventory.InventoryReference{
|
||||
SKU: inventory.SKU(item.Sku),
|
||||
LocationID: getLocationId(item),
|
||||
},
|
||||
Quantity: uint32(item.Quantity),
|
||||
})
|
||||
}
|
||||
return requests
|
||||
}
|
||||
|
||||
func getOriginalHost(r *http.Request) string {
|
||||
proxyHost := r.Header.Get("X-Forwarded-Host")
|
||||
if proxyHost != "" {
|
||||
return proxyHost
|
||||
}
|
||||
return r.Host
|
||||
}
|
||||
|
||||
func getClientIp(r *http.Request) string {
|
||||
ip := r.Header.Get("X-Forwarded-For")
|
||||
if ip == "" {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
func CookieCartIdHandler(fn func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var id cart.CartId
|
||||
cookie, err := r.Cookie("cartid")
|
||||
if err != nil || cookie.Value == "" {
|
||||
id = cart.MustNewCartId()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "cartid",
|
||||
Value: id.String(),
|
||||
Secure: r.TLS != nil,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
Expires: time.Now().AddDate(0, 0, 14),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
w.Header().Set("Set-Cart-Id", id.String())
|
||||
} else {
|
||||
parsed, ok := cart.ParseCartId(cookie.Value)
|
||||
if !ok {
|
||||
id = cart.MustNewCartId()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "cartid",
|
||||
Value: id.String(),
|
||||
Secure: r.TLS != nil,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
Expires: time.Now().AddDate(0, 0, 14),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
w.Header().Set("Set-Cart-Id", id.String())
|
||||
} else {
|
||||
id = parsed
|
||||
}
|
||||
}
|
||||
|
||||
err = fn(id, w, r)
|
||||
if err != nil {
|
||||
log.Printf("Server error, not remote error: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func CartIdHandler(fn func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var id cart.CartId
|
||||
raw := r.PathValue("id")
|
||||
// If no id supplied, generate a new one
|
||||
if raw == "" {
|
||||
id := cart.MustNewCartId()
|
||||
w.Header().Set("Set-Cart-Id", id.String())
|
||||
} else {
|
||||
// Parse base62 cart id
|
||||
if parsedId, ok := cart.ParseCartId(raw); !ok {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("cart id is invalid"))
|
||||
return
|
||||
} else {
|
||||
id = parsedId
|
||||
}
|
||||
}
|
||||
|
||||
err := fn(id, w, r)
|
||||
if err != nil {
|
||||
log.Printf("Server error, not remote error: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/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/hmacvalidator"
|
||||
"github.com/adyen/adyen-go-api-library/v21/src/webhook"
|
||||
"github.com/google/uuid"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/anypb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
type SessionRequest struct {
|
||||
SessionId *string `json:"sessionId,omitempty"`
|
||||
SessionResult string `json:"sessionResult"`
|
||||
SessionData *string `json:"sessionData,omitempty"`
|
||||
}
|
||||
|
||||
// func (s *CheckoutPoolServer) AdyenSessionHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||
|
||||
// grain, err := s.Get(r.Context(), uint64(cartId))
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// if r.Method == http.MethodGet {
|
||||
// service := s.adyenClient.Checkout()
|
||||
// req := service.PaymentsApi.GetResultOfPaymentSessionInput(pa).SessionResult(payload.SessionResult)
|
||||
// res, _, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// return s.WriteResult(w, res)
|
||||
// } else {
|
||||
// payload := &SessionRequest{}
|
||||
// if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
|
||||
// return err
|
||||
// }
|
||||
// service := s.adyenClient.Checkout()
|
||||
// req := service.PaymentsApi.GetResultOfPaymentSessionInput(payload.SessionId).SessionResult(payload.SessionResult)
|
||||
// res, _, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// return s.WriteResult(w, res)
|
||||
// }
|
||||
|
||||
// }
|
||||
|
||||
func getCheckoutIdFromNotificationItem(item webhook.NotificationRequestItem) (*cart.CartId, error) {
|
||||
cartId, ok := cart.ParseCartId(item.MerchantReference)
|
||||
if !ok {
|
||||
log.Printf("The notification does not have a valid cartId: %s", item.MerchantReference)
|
||||
return nil, errors.New("invalid cart id")
|
||||
}
|
||||
return &cartId, nil
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) AdyenHookHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var notificationRequest webhook.Webhook
|
||||
service := s.adyenClient.Checkout()
|
||||
if err := json.NewDecoder(r.Body).Decode(¬ificationRequest); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
for _, notificationItem := range *notificationRequest.NotificationItems {
|
||||
item := notificationItem.NotificationRequestItem
|
||||
log.Printf("Recieved notification event code: %s, %+v", item.EventCode, item)
|
||||
|
||||
isValid := hmacvalidator.ValidateHmac(item, hmacKey)
|
||||
if !isValid {
|
||||
log.Printf("notification hmac not valid %s, %v", item.EventCode, item)
|
||||
http.Error(w, "Invalid HMAC", http.StatusUnauthorized)
|
||||
return
|
||||
} else {
|
||||
// Marshal item data for PaymentEvent
|
||||
dataBytes, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
log.Printf("error marshaling item: %v", err)
|
||||
http.Error(w, "Error marshaling item", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
switch item.EventCode {
|
||||
case "CAPTURE":
|
||||
checkoutId, err := getCheckoutIdFromNotificationItem(item)
|
||||
if err != nil {
|
||||
log.Printf("Could not get checkout id: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Capture status: %v", item.Success)
|
||||
isSuccess := item.Success == "true"
|
||||
|
||||
// If successful, apply payment completed
|
||||
//if isSuccess {
|
||||
if err := s.ApplyAnywhere(r.Context(), *checkoutId,
|
||||
&messages.PaymentEvent{
|
||||
PaymentId: item.PspReference,
|
||||
Success: isSuccess,
|
||||
Name: item.EventCode,
|
||||
Data: &anypb.Any{Value: dataBytes},
|
||||
}, &messages.PaymentCompleted{
|
||||
PaymentId: item.PspReference,
|
||||
Status: item.Success,
|
||||
Amount: item.Amount.Value,
|
||||
Currency: item.Amount.Currency,
|
||||
ProcessorReference: &item.PspReference,
|
||||
CompletedAt: timestamppb.New(time.Now()),
|
||||
}); err != nil {
|
||||
http.Error(w, "Message not parsed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
//}
|
||||
|
||||
// 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":
|
||||
|
||||
isSuccess := item.Success == "true"
|
||||
log.Printf("Handling auth: %+v", item)
|
||||
checkoutId, err := getCheckoutIdFromNotificationItem(item)
|
||||
if err != nil {
|
||||
log.Printf("Could not get checkout id: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
msgs := []proto.Message{
|
||||
&messages.PaymentEvent{
|
||||
PaymentId: item.PspReference,
|
||||
Success: isSuccess,
|
||||
Name: item.EventCode,
|
||||
Data: &anypb.Any{Value: dataBytes},
|
||||
},
|
||||
}
|
||||
if isSuccess {
|
||||
msgs = append(msgs, &messages.PaymentCompleted{
|
||||
PaymentId: item.PspReference,
|
||||
Status: item.Success,
|
||||
Amount: item.Amount.Value,
|
||||
CompletedAt: timestamppb.Now(),
|
||||
})
|
||||
} else {
|
||||
msgs = append(msgs, &messages.PaymentDeclined{
|
||||
PaymentId: item.PspReference,
|
||||
Message: item.Reason,
|
||||
})
|
||||
}
|
||||
if err := s.ApplyAnywhere(r.Context(), *checkoutId, msgs...); err != nil {
|
||||
log.Printf("error applying authorization event: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// If successful authorization, trigger capture
|
||||
if isSuccess {
|
||||
|
||||
pspReference := item.PspReference
|
||||
uid := uuid.New().String()
|
||||
ref := checkoutId.String()
|
||||
req := service.ModificationsApi.CaptureAuthorisedPaymentInput(pspReference).IdempotencyKey(uid).PaymentCaptureRequest(adyenCheckout.PaymentCaptureRequest{
|
||||
Amount: adyenCheckout.Amount(item.Amount),
|
||||
MerchantAccount: "ElgigantenECOM",
|
||||
Reference: &ref,
|
||||
})
|
||||
res, _, err := service.ModificationsApi.CaptureAuthorisedPayment(r.Context(), req)
|
||||
if err != nil {
|
||||
log.Printf("Error capturing payment: %v", err)
|
||||
} else {
|
||||
// Capture requested. The order is NOT created here — we
|
||||
// only have a PSP reference, not an order. Adyen sends a
|
||||
// CAPTURE notification once the capture settles, and the
|
||||
// CAPTURE branch above creates the event-sourced order.
|
||||
log.Printf("Payment capture requested successfully: %+v", res)
|
||||
}
|
||||
}
|
||||
default:
|
||||
log.Printf("Unknown event code: %s", item.EventCode)
|
||||
log.Printf("Item data: %+v", item)
|
||||
isSuccess := item.Success == "true"
|
||||
checkoutId, err := getCheckoutIdFromNotificationItem(item)
|
||||
if err != nil {
|
||||
log.Printf("Could not get checkout id: %v", err)
|
||||
|
||||
} else {
|
||||
if err := s.ApplyAnywhere(r.Context(), *checkoutId, &messages.PaymentEvent{
|
||||
PaymentId: item.PspReference,
|
||||
Success: isSuccess,
|
||||
Name: item.EventCode,
|
||||
Data: &anypb.Any{Value: dataBytes},
|
||||
}); err != nil {
|
||||
log.Printf("error applying payment event: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
|
||||
}
|
||||
|
||||
// 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) {
|
||||
log.Println("Redirect received")
|
||||
|
||||
service := s.adyenClient.Checkout()
|
||||
|
||||
req := service.PaymentsApi.GetResultOfPaymentSessionInput(r.URL.Query().Get("sessionId"))
|
||||
|
||||
res, httpRes, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
|
||||
log.Printf("got payment session %+v", res)
|
||||
|
||||
dreq := service.PaymentsApi.PaymentsDetailsInput()
|
||||
dreq = dreq.PaymentDetailsRequest(adyenCheckout.PaymentDetailsRequest{
|
||||
Details: adyenCheckout.PaymentCompletionDetails{
|
||||
RedirectResult: common.PtrString(r.URL.Query().Get("redirectResult")),
|
||||
Payload: common.PtrString(r.URL.Query().Get("payload")),
|
||||
},
|
||||
})
|
||||
|
||||
dres, httpRes, err := service.PaymentsApi.PaymentsDetails(r.Context(), dreq)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
log.Printf("Payment details response: %+v", dres)
|
||||
|
||||
if !common.IsNil(dres.PspReference) && *dres.PspReference != "" {
|
||||
var redirectURL string
|
||||
// Conditionally handle different result codes for the shopper
|
||||
switch *dres.ResultCode {
|
||||
case "Authorised":
|
||||
redirectURL = "/result/success"
|
||||
case "Pending", "Received":
|
||||
redirectURL = "/result/pending"
|
||||
case "Refused":
|
||||
redirectURL = "/result/failed"
|
||||
default:
|
||||
reason := ""
|
||||
if dres.RefusalReason != nil {
|
||||
reason = *dres.RefusalReason
|
||||
} else {
|
||||
reason = *dres.ResultCode
|
||||
}
|
||||
log.Printf("Payment failed: %s", reason)
|
||||
redirectURL = fmt.Sprintf("/result/error?reason=%s", url.QueryEscape(reason))
|
||||
}
|
||||
http.Redirect(w, r, redirectURL, http.StatusFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(httpRes.StatusCode)
|
||||
json.NewEncoder(w).Encode(httpRes.Status)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
type AmqpOrderHandler struct {
|
||||
conn *amqp.Connection
|
||||
queue *amqp.Queue
|
||||
}
|
||||
|
||||
func NewAmqpOrderHandler(conn *amqp.Connection) *AmqpOrderHandler {
|
||||
return &AmqpOrderHandler{
|
||||
conn: conn,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AmqpOrderHandler) DefineQueue() error {
|
||||
ch, err := h.conn.Channel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open a channel: %w", err)
|
||||
}
|
||||
defer ch.Close()
|
||||
|
||||
queue, err := ch.QueueDeclare(
|
||||
"order-queue", // name
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to declare an exchange: %w", err)
|
||||
}
|
||||
h.queue = &queue
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AmqpOrderHandler) OrderCompleted(body []byte) error {
|
||||
ch, err := h.conn.Channel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open a channel: %w", err)
|
||||
}
|
||||
defer ch.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
return ch.PublishWithContext(ctx,
|
||||
"", // exchange
|
||||
h.queue.Name, // routing key
|
||||
false, // mandatory
|
||||
false, // immediate
|
||||
amqp.Publishing{
|
||||
//DeliveryMode: amqp.,
|
||||
ContentType: "application/json",
|
||||
Body: body,
|
||||
})
|
||||
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
)
|
||||
|
||||
type CartClient struct {
|
||||
httpClient *http.Client
|
||||
baseUrl string
|
||||
agentProfile string // UCP-Agent profile URI
|
||||
}
|
||||
|
||||
func NewCartClient(baseUrl string) *CartClient {
|
||||
profile := os.Getenv("UCP_AGENT_PROFILE")
|
||||
if profile == "" {
|
||||
profile = "https://checkout.k6n.net/.well-known/ucp"
|
||||
}
|
||||
return &CartClient{
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
baseUrl: baseUrl,
|
||||
agentProfile: profile,
|
||||
}
|
||||
}
|
||||
|
||||
// func (c *CartClient) ApplyMutation(cartId cart.CartId, mutation proto.Message) error {
|
||||
// url := fmt.Sprintf("%s/internal/cart/%s/mutation", c.baseUrl, cartId.String())
|
||||
// data, err := proto.Marshal(mutation)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// req, err := http.NewRequest("POST", url, bytes.NewReader(data))
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// req.Header.Set("Content-Type", "application/protobuf")
|
||||
// resp, err := c.httpClient.Do(req)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// defer resp.Body.Close()
|
||||
// if resp.StatusCode != http.StatusOK {
|
||||
// return fmt.Errorf("cart mutation failed: %s", resp.Status)
|
||||
// }
|
||||
// return nil
|
||||
// }
|
||||
|
||||
func (s *CartClient) getCartGrain(ctx context.Context, cartId cart.CartId) (*cart.CartGrain, error) {
|
||||
// Call cart service to get grain
|
||||
url := fmt.Sprintf("%s/cart/byid/%s", s.baseUrl, cartId.String())
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("UCP-Agent", `profile="`+s.agentProfile+`"`)
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Printf("request to %s failed with status %s", url, resp.Status)
|
||||
return nil, fmt.Errorf("failed to get cart: %s", resp.Status)
|
||||
}
|
||||
var grain cart.CartGrain
|
||||
err = json.NewDecoder(resp.Body).Decode(&grain)
|
||||
return &grain, err
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
)
|
||||
|
||||
// TestGetCartGrain_RealService tests against the actual service at https://cart.k6n.net/
|
||||
// This test is skipped by default and can be run with: go test -run TestGetCartGrain_RealService
|
||||
func TestGetCartGrain_RealService(t *testing.T) {
|
||||
t.Skip("Skipping integration test against real service")
|
||||
|
||||
client := NewCartClient("https://cart.k6n.net")
|
||||
|
||||
// You would need a real cart ID that exists in the system
|
||||
// For example: cartId := cart.NewCartId(123, 456)
|
||||
cartId := cart.MustParseCartId("JkfG6bRNLMy")
|
||||
|
||||
grain, err := client.getCartGrain(context.Background(), cartId)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get cart grain: %v", err)
|
||||
}
|
||||
|
||||
if grain == nil {
|
||||
t.Fatal("Expected grain to be non-nil")
|
||||
}
|
||||
|
||||
t.Logf("Successfully retrieved cart grain: %+v", grain)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout"
|
||||
"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
|
||||
// Klarna CheckoutOrder from a CartGrain snapshot. It deliberately excludes
|
||||
// any Klarna-specific response fields (HTML snippet, client token, etc.).
|
||||
type CheckoutMeta struct {
|
||||
SiteUrl string
|
||||
// CallbackBaseUrl is the base for Klarna server-to-server callbacks
|
||||
// (push/notification/validate); may differ from SiteUrl in prod where the
|
||||
// checkout service and storefront are on different hosts.
|
||||
CallbackBaseUrl string
|
||||
ClientIp string
|
||||
Country string
|
||||
Currency string // optional override (defaults to "SEK" if empty)
|
||||
Locale string // optional override (defaults to "sv-se" if empty)
|
||||
}
|
||||
|
||||
// BuildCheckoutOrderPayload converts the current cart grain + meta information
|
||||
// into a CheckoutOrder domain struct and returns its JSON-serialized payload
|
||||
// (to send to Klarna) alongside the structured CheckoutOrder object.
|
||||
//
|
||||
// This function is PURE: it does not perform any network I/O or mutate the
|
||||
// grain. The caller is responsible for:
|
||||
//
|
||||
// 1. Choosing whether to create or update the Klarna order.
|
||||
// 2. Invoking KlarnaClient.CreateOrder / UpdateOrder with the returned payload.
|
||||
// 3. Applying an InitializeCheckout mutation (or equivalent) with the
|
||||
// resulting Klarna order id + status.
|
||||
//
|
||||
// If you later need to support different tax rates per line, you can extend
|
||||
// CartItem / Delivery to expose that data and propagate it here.
|
||||
func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
|
||||
if grain == nil {
|
||||
return nil, nil, fmt.Errorf("nil grain")
|
||||
}
|
||||
if meta == nil {
|
||||
return nil, nil, fmt.Errorf("nil checkout meta")
|
||||
}
|
||||
|
||||
currency := meta.Currency
|
||||
if currency == "" {
|
||||
currency = "SEK"
|
||||
}
|
||||
locale := meta.Locale
|
||||
if locale == "" {
|
||||
locale = "sv-se"
|
||||
}
|
||||
country := meta.Country
|
||||
if country == "" {
|
||||
country = "SE" // sensible default; adjust if multi-country support changes
|
||||
}
|
||||
|
||||
lines := make([]*Line, 0, len(grain.CartState.Items)+len(grain.Deliveries))
|
||||
|
||||
// Item lines
|
||||
for _, it := range grain.CartState.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, &Line{
|
||||
Type: "physical",
|
||||
Reference: it.Sku,
|
||||
Name: it.Meta.Name,
|
||||
Quantity: int(it.Quantity),
|
||||
UnitPrice: int(it.Price.IncVat),
|
||||
TaxRate: it.Tax, // TODO: derive if variable tax rates are introduced
|
||||
QuantityUnit: "st",
|
||||
TotalAmount: int(it.TotalPrice.IncVat),
|
||||
TotalTaxAmount: int(it.TotalPrice.TotalVat()),
|
||||
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", it.Meta.Image),
|
||||
})
|
||||
}
|
||||
|
||||
total := cart.NewPrice()
|
||||
total.Add(*grain.CartState.TotalPrice)
|
||||
|
||||
// Delivery lines
|
||||
for _, d := range grain.Deliveries {
|
||||
if d == nil || d.Price.IncVat <= 0 {
|
||||
continue
|
||||
}
|
||||
//total.Add(d.Price)
|
||||
lines = append(lines, &Line{
|
||||
Type: "shipping_fee",
|
||||
Reference: d.Provider,
|
||||
Name: "Delivery",
|
||||
Quantity: 1,
|
||||
UnitPrice: int(d.Price.IncVat),
|
||||
TaxRate: 2500,
|
||||
QuantityUnit: "st",
|
||||
TotalAmount: int(d.Price.IncVat),
|
||||
TotalTaxAmount: int(d.Price.TotalVat()),
|
||||
})
|
||||
}
|
||||
|
||||
order := &CheckoutOrder{
|
||||
PurchaseCountry: country,
|
||||
PurchaseCurrency: currency,
|
||||
Locale: locale,
|
||||
OrderAmount: int(total.IncVat),
|
||||
OrderTaxAmount: int(total.TotalVat()),
|
||||
OrderLines: lines,
|
||||
MerchantReference1: grain.Id.String(),
|
||||
MerchantURLS: &CheckoutMerchantURLS{
|
||||
Terms: fmt.Sprintf("%s/terms", meta.SiteUrl),
|
||||
Checkout: fmt.Sprintf("%s/kassa?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl),
|
||||
Confirmation: fmt.Sprintf("%s/kassa?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl),
|
||||
Notification: fmt.Sprintf("%s/payment/klarna/notification", meta.CallbackBaseUrl),
|
||||
Validation: fmt.Sprintf("%s/payment/klarna/validate", meta.CallbackBaseUrl),
|
||||
Push: fmt.Sprintf("%s/payment/klarna/push?order_id={checkout.order.id}", meta.CallbackBaseUrl),
|
||||
},
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(order)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("marshal checkout order: %w", err)
|
||||
}
|
||||
|
||||
return payload, order, nil
|
||||
}
|
||||
|
||||
func GetCheckoutMetaFromRequest(r *http.Request) *CheckoutMeta {
|
||||
host := getOriginalHost(r)
|
||||
country := getCountryFromHost(host)
|
||||
siteUrl := fmt.Sprintf("%s://%s", getScheme(r), host)
|
||||
if checkoutPublicURL != "" {
|
||||
siteUrl = checkoutPublicURL
|
||||
}
|
||||
return &CheckoutMeta{
|
||||
ClientIp: getClientIp(r),
|
||||
SiteUrl: siteUrl,
|
||||
CallbackBaseUrl: checkoutCallbackBaseURL,
|
||||
Country: country,
|
||||
Currency: getCurrency(country),
|
||||
Locale: getLocale(country),
|
||||
}
|
||||
}
|
||||
|
||||
func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta) (*adyenCheckout.CreateCheckoutSessionRequest, error) {
|
||||
if grain == nil {
|
||||
return nil, fmt.Errorf("nil grain")
|
||||
}
|
||||
if meta == nil {
|
||||
return nil, fmt.Errorf("nil checkout meta")
|
||||
}
|
||||
|
||||
currency := meta.Currency
|
||||
if currency == "" {
|
||||
currency = "SEK"
|
||||
}
|
||||
country := meta.Country
|
||||
if country == "" {
|
||||
country = "SE"
|
||||
}
|
||||
|
||||
lineItems := make([]adyenCheckout.LineItem, 0, len(grain.CartState.Items)+len(grain.Deliveries))
|
||||
|
||||
// Item lines
|
||||
for _, it := range grain.CartState.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
lineItems = append(lineItems, adyenCheckout.LineItem{
|
||||
Quantity: common.PtrInt64(int64(it.Quantity)),
|
||||
AmountIncludingTax: common.PtrInt64(it.TotalPrice.IncVat),
|
||||
Description: common.PtrString(it.Meta.Name),
|
||||
AmountExcludingTax: common.PtrInt64(it.TotalPrice.ValueExVat()),
|
||||
TaxAmount: common.PtrInt64(it.TotalPrice.TotalVat()),
|
||||
TaxPercentage: common.PtrInt64(int64(it.Tax)),
|
||||
})
|
||||
}
|
||||
total := cart.NewPrice()
|
||||
total.Add(*grain.CartState.TotalPrice)
|
||||
|
||||
// Delivery lines
|
||||
for _, d := range grain.Deliveries {
|
||||
if d == nil || d.Price.IncVat <= 0 {
|
||||
continue
|
||||
}
|
||||
lineItems = append(lineItems, adyenCheckout.LineItem{
|
||||
Quantity: common.PtrInt64(1),
|
||||
AmountIncludingTax: common.PtrInt64(d.Price.IncVat),
|
||||
Description: common.PtrString("Delivery"),
|
||||
AmountExcludingTax: common.PtrInt64(d.Price.ValueExVat()),
|
||||
TaxPercentage: common.PtrInt64(25),
|
||||
})
|
||||
}
|
||||
|
||||
return &adyenCheckout.CreateCheckoutSessionRequest{
|
||||
Reference: grain.Id.String(),
|
||||
Amount: adyenCheckout.Amount{
|
||||
Value: total.IncVat,
|
||||
Currency: currency,
|
||||
},
|
||||
CountryCode: common.PtrString(country),
|
||||
MerchantAccount: "ElgigantenECOM",
|
||||
Channel: common.PtrString("Web"),
|
||||
ShopperIP: common.PtrString(meta.ClientIp),
|
||||
ReturnUrl: fmt.Sprintf("%s/payment/adyen/return", meta.SiteUrl),
|
||||
LineItems: lineItems,
|
||||
}, nil
|
||||
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/discovery"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
)
|
||||
|
||||
func GetDiscovery() discovery.Discovery {
|
||||
if podIp == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
config, kerr := rest.InClusterConfig()
|
||||
|
||||
if kerr != nil {
|
||||
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
|
||||
}
|
||||
client, err := kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating client: %v\n", err)
|
||||
}
|
||||
timeout := int64(30)
|
||||
return discovery.NewK8sDiscovery(client, v1.ListOptions{
|
||||
LabelSelector: "actor-pool=checkout",
|
||||
TimeoutSeconds: &timeout,
|
||||
})
|
||||
}
|
||||
|
||||
func UseDiscovery(pool discovery.DiscoveryTarget) {
|
||||
|
||||
go func(hw discovery.Discovery) {
|
||||
if hw == nil {
|
||||
log.Print("No discovery service available")
|
||||
return
|
||||
}
|
||||
ch, err := hw.Watch()
|
||||
if err != nil {
|
||||
log.Printf("Discovery error: %v", err)
|
||||
return
|
||||
}
|
||||
for evt := range ch {
|
||||
if evt.Host == "" {
|
||||
continue
|
||||
}
|
||||
switch evt.IsReady {
|
||||
case false:
|
||||
if pool.IsKnown(evt.Host) {
|
||||
log.Printf("Host %s is not ready, removing", evt.Host)
|
||||
pool.RemoveHost(evt.Host)
|
||||
}
|
||||
default:
|
||||
if !pool.IsKnown(evt.Host) {
|
||||
log.Printf("Discovered host %s", evt.Host)
|
||||
pool.AddRemoteHost(evt.Host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}(GetDiscovery())
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -30,11 +31,14 @@ const (
|
||||
KlarnaPlaygroundUrl = "https://api.playground.klarna.com"
|
||||
)
|
||||
|
||||
func (k *KlarnaClient) GetOrder(orderId string) (*CheckoutOrder, error) {
|
||||
func (k *KlarnaClient) GetOrder(ctx context.Context, orderId string) (*CheckoutOrder, error) {
|
||||
req, err := http.NewRequest("GET", k.Url+"/checkout/v3/orders/"+orderId, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spanCtx, span := tracer.Start(ctx, "Get klarna order")
|
||||
defer span.End()
|
||||
req = req.WithContext(spanCtx)
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.SetBasicAuth(k.UserName, k.Password)
|
||||
|
||||
@@ -64,17 +68,19 @@ func (k *KlarnaClient) getOrderResponse(res *http.Response) (*CheckoutOrder, err
|
||||
return nil, fmt.Errorf("%s", res.Status)
|
||||
}
|
||||
|
||||
func (k *KlarnaClient) CreateOrder(reader io.Reader) (*CheckoutOrder, error) {
|
||||
func (k *KlarnaClient) CreateOrder(ctx context.Context, reader io.Reader) (*CheckoutOrder, error) {
|
||||
//bytes.NewReader(reply.Payload)
|
||||
req, err := http.NewRequest("POST", k.Url+"/checkout/v3/orders", reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spanCtx, span := tracer.Start(ctx, "Create klarna order")
|
||||
defer span.End()
|
||||
req = req.WithContext(spanCtx)
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.SetBasicAuth(k.UserName, k.Password)
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
res, err := k.client.Do(req)
|
||||
if nil != err {
|
||||
return nil, err
|
||||
}
|
||||
@@ -82,17 +88,20 @@ func (k *KlarnaClient) CreateOrder(reader io.Reader) (*CheckoutOrder, error) {
|
||||
return k.getOrderResponse(res)
|
||||
}
|
||||
|
||||
func (k *KlarnaClient) UpdateOrder(orderId string, reader io.Reader) (*CheckoutOrder, error) {
|
||||
func (k *KlarnaClient) UpdateOrder(ctx context.Context, orderId string, reader io.Reader) (*CheckoutOrder, error) {
|
||||
//bytes.NewReader(reply.Payload)
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkout/v3/orders/%s", k.Url, orderId), reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spanCtx, span := tracer.Start(ctx, "Update klarna order")
|
||||
defer span.End()
|
||||
req = req.WithContext(spanCtx)
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.SetBasicAuth(k.UserName, k.Password)
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
res, err := k.client.Do(req)
|
||||
if nil != err {
|
||||
return nil, err
|
||||
}
|
||||
@@ -100,29 +109,34 @@ func (k *KlarnaClient) UpdateOrder(orderId string, reader io.Reader) (*CheckoutO
|
||||
return k.getOrderResponse(res)
|
||||
}
|
||||
|
||||
func (k *KlarnaClient) AbortOrder(orderId string) error {
|
||||
func (k *KlarnaClient) AbortOrder(ctx context.Context, orderId string) error {
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkout/v3/orders/%s/abort", k.Url, orderId), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
spanCtx, span := tracer.Start(ctx, "Abort klarna order")
|
||||
defer span.End()
|
||||
req = req.WithContext(spanCtx)
|
||||
req.SetBasicAuth(k.UserName, k.Password)
|
||||
|
||||
_, err = http.DefaultClient.Do(req)
|
||||
_, err = k.client.Do(req)
|
||||
return err
|
||||
}
|
||||
|
||||
// ordermanagement/v1/orders/{order_id}/acknowledge
|
||||
func (k *KlarnaClient) AcknowledgeOrder(orderId string) error {
|
||||
func (k *KlarnaClient) AcknowledgeOrder(ctx context.Context, orderId string) error {
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("%s/ordermanagement/v1/orders/%s/acknowledge", k.Url, orderId), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spanCtx, span := tracer.Start(ctx, "Acknowledge klarna order")
|
||||
defer span.End()
|
||||
req = req.WithContext(spanCtx)
|
||||
id := uuid.New()
|
||||
|
||||
req.SetBasicAuth(k.UserName, k.Password)
|
||||
req.Header.Add("Klarna-Idempotency-Key", id.String())
|
||||
|
||||
_, err = http.DefaultClient.Do(req)
|
||||
_, err = k.client.Do(req)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
|
||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
/*
|
||||
*
|
||||
*
|
||||
* s.CheckoutHandler(func(order *CheckoutOrder, w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, err := fmt.Fprintf(w, tpl, order.HTMLSnippet)
|
||||
return err
|
||||
})
|
||||
*/
|
||||
|
||||
// func (s *CheckoutPoolServer) KlarnaHtmlCheckoutHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
|
||||
|
||||
// orderId := r.URL.Query().Get("order_id")
|
||||
// var order *CheckoutOrder
|
||||
// var err error
|
||||
// if orderId == "" {
|
||||
// order, err = s.CreateOrUpdateCheckout(r, checkoutId)
|
||||
// if err != nil {
|
||||
// logger.Error("unable to create klarna session", "error", err)
|
||||
// return err
|
||||
// }
|
||||
// // s.ApplyKlarnaPaymentStarted(r.Context(), order, checkoutId)
|
||||
|
||||
// }
|
||||
// order, err = s.klarnaClient.GetOrder(r.Context(), orderId)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
// w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
|
||||
// w.WriteHeader(http.StatusOK)
|
||||
// _, err = fmt.Fprintf(w, tpl, order.HTMLSnippet)
|
||||
// return err
|
||||
|
||||
// }
|
||||
|
||||
// func (s *CheckoutPoolServer) KlarnaSessionHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
|
||||
|
||||
// orderId := r.URL.Query().Get("order_id")
|
||||
// var order *CheckoutOrder
|
||||
// var err error
|
||||
// if orderId == "" {
|
||||
// order, err = s.CreateOrUpdateCheckout(r, checkoutId)
|
||||
// if err != nil {
|
||||
// logger.Error("unable to create klarna session", "error", err)
|
||||
// return err
|
||||
// }
|
||||
// // s.ApplyKlarnaPaymentStarted(r.Context(), order, checkoutId)
|
||||
|
||||
// }
|
||||
// order, err = s.klarnaClient.GetOrder(r.Context(), orderId)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
// return json.NewEncoder(w).Encode(order)
|
||||
|
||||
// }
|
||||
|
||||
func (s *CheckoutPoolServer) KlarnaConfirmationHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
orderId := r.PathValue("order_id")
|
||||
order, err := s.klarnaClient.GetOrder(r.Context(), orderId)
|
||||
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Apply ConfirmationViewed mutation
|
||||
cartId, ok := cart.ParseCartId(order.MerchantReference1)
|
||||
if ok {
|
||||
s.Apply(r.Context(), uint64(cartId), &messages.ConfirmationViewed{})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if order.Status == "checkout_complete" {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "cartid",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
Expires: time.Unix(0, 0),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, tpl, order.HTMLSnippet)
|
||||
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) KlarnaValidationHandler(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Klarna order validation, method: %s", r.Method)
|
||||
if r.Method != "POST" {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
order := &CheckoutOrder{}
|
||||
err := json.NewDecoder(r.Body).Decode(order)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
logger.InfoContext(r.Context(), "Klarna order validation received", "order_id", order.ID, "cart_id", order.MerchantReference1)
|
||||
grain, err := s.getGrainFromKlarnaOrder(r.Context(), order)
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "Unable to get grain from klarna order", "error", err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.reserveInventory(r.Context(), grain)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) KlarnaNotificationHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
log.Printf("Klarna order notification, method: %s", r.Method)
|
||||
logger.InfoContext(r.Context(), "Klarna order notification received", "method", r.Method)
|
||||
if r.Method != "POST" {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
order := &CheckoutOrder{}
|
||||
err := json.NewDecoder(r.Body).Decode(order)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.Printf("Klarna order notification: %s", order.ID)
|
||||
logger.InfoContext(r.Context(), "Klarna order notification received", "order_id", order.ID)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Klarna order confirmation push, method: %s", r.Method)
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
orderId := r.URL.Query().Get("order_id")
|
||||
log.Printf("Order confirmation push: %s", orderId)
|
||||
|
||||
order, err := s.klarnaClient.GetOrder(r.Context(), orderId)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error creating request: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
grain, err := s.getGrainFromKlarnaOrder(r.Context(), order)
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "Unable to get grain from klarna order", "error", err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if s.inventoryService != nil {
|
||||
inventoryRequests := getInventoryRequests(grain.CartState.Items)
|
||||
invStatus := "success"
|
||||
if err = s.inventoryService.ReserveInventory(r.Context(), inventoryRequests...); err != nil {
|
||||
// The payment is already settled at Klarna (checkout_complete), so the
|
||||
// order MUST be created. Inventory is best-effort at this point — a
|
||||
// reservation failure (unstocked / drop-ship / non-tracked items)
|
||||
// must not block order creation. Log it and flag the grain so
|
||||
// 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{
|
||||
Id: grain.Id.String(),
|
||||
Status: invStatus,
|
||||
})
|
||||
}
|
||||
|
||||
s.ApplyAnywhere(r.Context(), grain.Id, &messages.PaymentCompleted{
|
||||
PaymentId: orderId,
|
||||
Status: "completed",
|
||||
ProcessorReference: &order.ID,
|
||||
Amount: int64(order.OrderAmount),
|
||||
Currency: order.PurchaseCurrency,
|
||||
CompletedAt: timestamppb.Now(),
|
||||
})
|
||||
|
||||
// ── Create the event-sourced order grain (dual-write with AMQP) ────
|
||||
if s.orderClient != nil {
|
||||
if _, orderErr := createOrderFromCheckout(r.Context(), s, grain, order, "klarna"); orderErr != nil {
|
||||
// Non-fatal: the checkout grain is updated, the order can be
|
||||
// created on retry (idempotency key guards against duplicates).
|
||||
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 = s.klarnaClient.AcknowledgeOrder(r.Context(), orderId)
|
||||
if err != nil {
|
||||
log.Printf("Error acknowledging order: %v\n", err)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
var tpl = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>s10r testing - checkout</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
%s
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
// 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 {
|
||||
if item.StoreId == nil || *item.StoreId == "" {
|
||||
return "se"
|
||||
}
|
||||
return inventory.LocationID(*item.StoreId)
|
||||
}
|
||||
|
||||
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
|
||||
var requests []inventory.ReserveRequest
|
||||
for _, item := range items {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
requests = append(requests, inventory.ReserveRequest{
|
||||
InventoryReference: &inventory.InventoryReference{
|
||||
SKU: inventory.SKU(item.Sku),
|
||||
LocationID: getLocationId(item),
|
||||
},
|
||||
Quantity: uint32(item.Quantity),
|
||||
})
|
||||
}
|
||||
return requests
|
||||
}
|
||||
|
||||
func (a *CheckoutPoolServer) getGrainFromKlarnaOrder(ctx context.Context, order *CheckoutOrder) (*checkout.CheckoutGrain, error) {
|
||||
cartId, ok := cart.ParseCartId(order.MerchantReference1)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1)
|
||||
}
|
||||
grain, err := a.GetAnywhere(ctx, cartId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get cart grain: %w", err)
|
||||
}
|
||||
return grain, nil
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"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/checkout"
|
||||
"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/common"
|
||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
)
|
||||
|
||||
var (
|
||||
grainSpawns = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "checkout_grain_spawned_total",
|
||||
Help: "The total number of spawned checkout grains",
|
||||
})
|
||||
)
|
||||
|
||||
func init() {
|
||||
os.Mkdir("data", 0755)
|
||||
}
|
||||
|
||||
type App struct {
|
||||
pool *actor.SimpleGrainPool[checkout.CheckoutGrain]
|
||||
server *CheckoutPoolServer
|
||||
klarnaClient *KlarnaClient
|
||||
cartClient *CartClient // For internal communication to cart
|
||||
}
|
||||
|
||||
var podIp = os.Getenv("POD_IP")
|
||||
var name = os.Getenv("POD_NAME")
|
||||
var amqpUrl = os.Getenv("AMQP_URL")
|
||||
var redisAddress = os.Getenv("REDIS_ADDRESS")
|
||||
var redisPassword = os.Getenv("REDIS_PASSWORD")
|
||||
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() {
|
||||
|
||||
controlPlaneConfig := actor.DefaultServerConfig()
|
||||
|
||||
reg := checkout.NewCheckoutMutationRegistry(checkout.NewCheckoutMutationContext())
|
||||
reg.RegisterProcessor(
|
||||
actor.NewMutationProcessor(func(ctx context.Context, g *checkout.CheckoutGrain) error {
|
||||
g.Version++
|
||||
return 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)
|
||||
}
|
||||
|
||||
checkoutDir := os.Getenv("CHECKOUT_DIR")
|
||||
if checkoutDir == "" {
|
||||
checkoutDir = "data"
|
||||
}
|
||||
diskStorage := actor.NewDiskStorage[checkout.CheckoutGrain](checkoutDir, reg)
|
||||
|
||||
poolConfig := actor.GrainPoolConfig[checkout.CheckoutGrain]{
|
||||
MutationRegistry: reg,
|
||||
Storage: diskStorage,
|
||||
Spawn: func(ctx context.Context, id uint64) (actor.Grain[checkout.CheckoutGrain], error) {
|
||||
_, span := tracer.Start(ctx, fmt.Sprintf("Spawn checkout id %d", id))
|
||||
defer span.End()
|
||||
grainSpawns.Inc()
|
||||
|
||||
ret := checkout.NewCheckoutGrain(id, 0, 0, time.Now(), nil) // version to be set later
|
||||
// Load persisted events/state for this checkout if present
|
||||
if err := diskStorage.LoadEvents(ctx, id, ret); err != nil {
|
||||
// Return the grain along with error (e.g., not found) so callers can decide
|
||||
return ret, err
|
||||
}
|
||||
return ret, nil
|
||||
},
|
||||
Destroy: func(grain actor.Grain[checkout.CheckoutGrain]) error {
|
||||
return nil
|
||||
},
|
||||
SpawnHost: func(host string) (actor.Host[checkout.CheckoutGrain], error) {
|
||||
return proxy.NewRemoteHost[checkout.CheckoutGrain](host)
|
||||
},
|
||||
TTL: 1 * time.Hour, // Longer TTL for checkout
|
||||
PoolSize: 65535,
|
||||
Hostname: podIp,
|
||||
}
|
||||
|
||||
pool, err := actor.NewSimpleGrainPool(poolConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating checkout pool: %v\n", err)
|
||||
}
|
||||
|
||||
adyenClient := adyen.NewClient(&common.Config{
|
||||
ApiKey: os.Getenv("ADYEN_API_KEY"),
|
||||
Environment: common.TestEnv,
|
||||
})
|
||||
|
||||
klarnaClient := NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
|
||||
|
||||
cartClient := NewCartClient(cartInternalUrl)
|
||||
|
||||
var orderClient *OrderClient
|
||||
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
|
||||
|
||||
mux := http.NewServeMux()
|
||||
debugMux := http.NewServeMux()
|
||||
|
||||
if amqpUrl == "" {
|
||||
log.Fatalf("no connection to amqp defined")
|
||||
}
|
||||
|
||||
grpcSrv, err := actor.NewControlServer[checkout.CheckoutGrain](controlPlaneConfig, pool)
|
||||
if err != nil {
|
||||
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
|
||||
}
|
||||
defer grpcSrv.GracefulStop()
|
||||
|
||||
UseDiscovery(pool)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
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("control plane not healthy"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("1.0.0"))
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":8080",
|
||||
BaseContext: func(net.Listener) context.Context { return ctx },
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 20 * time.Second,
|
||||
Handler: otelhttp.NewHandler(mux, "/"),
|
||||
}
|
||||
|
||||
defer func() {
|
||||
fmt.Println("Shutting down due to signal")
|
||||
otelShutdown(context.Background())
|
||||
diskStorage.Close()
|
||||
pool.Close()
|
||||
}()
|
||||
|
||||
srvErr := make(chan error, 1)
|
||||
go func() {
|
||||
srvErr <- srv.ListenAndServe()
|
||||
}()
|
||||
|
||||
log.Print("Checkout 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||
"go.opentelemetry.io/otel/log/global"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/sdk/log"
|
||||
"go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/trace"
|
||||
)
|
||||
|
||||
// setupOTelSDK bootstraps the OpenTelemetry pipeline.
|
||||
// If it does not return an error, make sure to call shutdown for proper cleanup.
|
||||
func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
|
||||
var shutdownFuncs []func(context.Context) error
|
||||
var err error
|
||||
|
||||
// shutdown calls cleanup functions registered via shutdownFuncs.
|
||||
// The errors from the calls are joined.
|
||||
// Each registered cleanup will be invoked once.
|
||||
shutdown := func(ctx context.Context) error {
|
||||
var err error
|
||||
for _, fn := range shutdownFuncs {
|
||||
err = errors.Join(err, fn(ctx))
|
||||
}
|
||||
shutdownFuncs = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// handleErr calls shutdown for cleanup and makes sure that all errors are returned.
|
||||
handleErr := func(inErr error) {
|
||||
err = errors.Join(inErr, shutdown(ctx))
|
||||
}
|
||||
|
||||
// Set up propagator.
|
||||
prop := newPropagator()
|
||||
otel.SetTextMapPropagator(prop)
|
||||
|
||||
// Set up trace provider.
|
||||
tracerProvider, err := newTracerProvider()
|
||||
if err != nil {
|
||||
handleErr(err)
|
||||
return shutdown, err
|
||||
}
|
||||
shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown)
|
||||
otel.SetTracerProvider(tracerProvider)
|
||||
|
||||
// Set up meter provider.
|
||||
meterProvider, err := newMeterProvider()
|
||||
if err != nil {
|
||||
handleErr(err)
|
||||
return shutdown, err
|
||||
}
|
||||
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
|
||||
otel.SetMeterProvider(meterProvider)
|
||||
|
||||
// Set up logger provider.
|
||||
loggerProvider, err := newLoggerProvider()
|
||||
if err != nil {
|
||||
handleErr(err)
|
||||
return shutdown, err
|
||||
}
|
||||
shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown)
|
||||
global.SetLoggerProvider(loggerProvider)
|
||||
|
||||
return shutdown, err
|
||||
}
|
||||
|
||||
func newPropagator() propagation.TextMapPropagator {
|
||||
return propagation.NewCompositeTextMapPropagator(
|
||||
propagation.TraceContext{},
|
||||
propagation.Baggage{},
|
||||
)
|
||||
}
|
||||
|
||||
func newTracerProvider() (*trace.TracerProvider, error) {
|
||||
traceExporter, err := otlptracegrpc.New(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tracerProvider := trace.NewTracerProvider(
|
||||
trace.WithBatcher(traceExporter,
|
||||
// Default is 5s. Set to 1s for demonstrative purposes.
|
||||
trace.WithBatchTimeout(time.Second)),
|
||||
)
|
||||
return tracerProvider, nil
|
||||
}
|
||||
|
||||
func newMeterProvider() (*metric.MeterProvider, error) {
|
||||
exporter, err := otlpmetricgrpc.New(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
provider := metric.NewMeterProvider(metric.WithReader(metric.NewPeriodicReader(exporter)))
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func newLoggerProvider() (*log.LoggerProvider, error) {
|
||||
logExporter, err := otlploggrpc.New(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
loggerProvider := log.NewLoggerProvider(
|
||||
log.WithProcessor(log.NewBatchProcessor(logExporter)),
|
||||
)
|
||||
return loggerProvider, nil
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"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"
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
|
||||
|
||||
adyen "github.com/adyen/adyen-go-api-library/v21/src/adyen"
|
||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/anypb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
var (
|
||||
grainMutations = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "checkout_grain_mutations_total",
|
||||
Help: "The total number of mutations",
|
||||
})
|
||||
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "checkout_grain_lookups_total",
|
||||
Help: "The total number of lookups",
|
||||
})
|
||||
)
|
||||
|
||||
type CheckoutPoolServer struct {
|
||||
actor.GrainPool[checkout.CheckoutGrain]
|
||||
pod_name string
|
||||
klarnaClient *KlarnaClient
|
||||
adyenClient *adyen.APIClient
|
||||
cartClient *CartClient
|
||||
orderClient *OrderClient
|
||||
orderHandler *AmqpOrderHandler
|
||||
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 {
|
||||
srv := &CheckoutPoolServer{
|
||||
GrainPool: pool,
|
||||
pod_name: pod_name,
|
||||
klarnaClient: klarnaClient,
|
||||
cartClient: cartClient,
|
||||
adyenClient: adyenClient,
|
||||
orderClient: orderClient,
|
||||
orderHandler: orderHandler,
|
||||
}
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) ApplyLocal(ctx context.Context, id checkout.CheckoutId, mutation ...proto.Message) (*actor.MutationResult[checkout.CheckoutGrain], error) {
|
||||
return s.Apply(ctx, uint64(id), mutation...)
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) GetCheckoutHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
|
||||
grain, err := s.Get(r.Context(), uint64(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.WriteResult(w, grain)
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) SetDeliveryHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
|
||||
var msg messages.SetDelivery
|
||||
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.ApplyLocal(r.Context(), id, &msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.WriteResult(w, result)
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) RemoveDeliveryHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
|
||||
deliveryId := r.PathValue("id")
|
||||
uintDeliveryId, err := strconv.ParseUint(deliveryId, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg := &messages.RemoveDelivery{
|
||||
Id: uint32(uintDeliveryId),
|
||||
}
|
||||
|
||||
result, err := s.ApplyLocal(r.Context(), id, msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.WriteResult(w, result)
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) SetPickupPointHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
|
||||
var msg messages.SetPickupPoint
|
||||
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.ApplyLocal(r.Context(), id, &msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.WriteResult(w, result)
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) InitializeCheckoutHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
|
||||
var msg messages.InitializeCheckout
|
||||
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.ApplyLocal(r.Context(), id, &msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.WriteResult(w, result)
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) InventoryReservedHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
|
||||
var msg messages.InventoryReserved
|
||||
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.ApplyLocal(r.Context(), id, &msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.WriteResult(w, result)
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) OrderCreatedHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
|
||||
var msg messages.OrderCreated
|
||||
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.ApplyLocal(r.Context(), id, &msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.WriteResult(w, result)
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) ConfirmationViewedHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
|
||||
var msg messages.ConfirmationViewed
|
||||
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.ApplyLocal(r.Context(), id, &msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.WriteResult(w, result)
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) ContactDetailsUpdatedHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
|
||||
var msg messages.ContactDetailsUpdated
|
||||
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.ApplyLocal(r.Context(), id, &msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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) {
|
||||
cartIdStr := r.PathValue("cartid")
|
||||
if cartIdStr == "" {
|
||||
http.Error(w, "cart id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cartId, ok := cart.ParseCartId(cartIdStr)
|
||||
if !ok {
|
||||
http.Error(w, "invalid cart id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch cart state from cart service
|
||||
cartGrain, err := s.cartClient.getCartGrain(r.Context(), cartId)
|
||||
if err != nil {
|
||||
logger.Error("failed to fetch cart", "error", err, "cartId", cartId)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Serialize cart state to Any
|
||||
cartStateBytes, err := json.Marshal(cartGrain)
|
||||
if err != nil {
|
||||
logger.Error("failed to marshal cart state", "error", err)
|
||||
http.Error(w, "failed to process cart state", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// The checkout grain is 1:1 with the cart it checks out (CheckoutId ==
|
||||
// CartId). Keying it by the cart id — not a lingering checkoutid cookie —
|
||||
// means a fresh cart always gets a fresh checkout, so the Klarna order
|
||||
// reflects the cart shown at /kassa. Reusing the cookie made a new cart's
|
||||
// checkout latch onto a previous cart's frozen snapshot (InitializeCheckout
|
||||
// silently refuses to re-snapshot a grain that already has a CartState).
|
||||
checkoutId := checkout.CheckoutId(cartId)
|
||||
|
||||
// Initialize checkout with cart state wrapped in Any
|
||||
cartStateAny := &messages.InitializeCheckout{
|
||||
OrderId: "",
|
||||
CartId: uint64(cartId),
|
||||
Version: uint32(cartGrain.Version),
|
||||
CartState: &anypb.Any{
|
||||
TypeUrl: "type.googleapis.com/cart.CartGrain",
|
||||
Value: cartStateBytes,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := s.ApplyLocal(r.Context(), checkoutId, cartStateAny)
|
||||
if err != nil {
|
||||
setCheckoutCookie(w, 0, r.TLS != nil)
|
||||
logger.Error("failed to initialize checkout", "error", err)
|
||||
http.Error(w, "failed to initialize checkout", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Set checkout cookie
|
||||
setCheckoutCookie(w, checkoutId, r.TLS != nil)
|
||||
|
||||
if err := s.WriteResult(w, &result.Result); err != nil {
|
||||
logger.Error("failed to write result", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) WriteResult(w http.ResponseWriter, result any) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("X-Pod-Name", s.pod_name)
|
||||
if result == nil {
|
||||
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
|
||||
return nil
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
enc := json.NewEncoder(w)
|
||||
err := enc.Encode(result)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) CreateOrUpdateCheckout(r *http.Request, grain *checkout.CheckoutGrain, orderId *string) (*CheckoutOrder, error) {
|
||||
|
||||
meta := GetCheckoutMetaFromRequest(r)
|
||||
|
||||
payload, _, err := BuildCheckoutOrderPayload(grain, meta)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var payment *checkout.Payment
|
||||
if orderId != nil {
|
||||
payment, _ = grain.FindPayment(*orderId)
|
||||
}
|
||||
|
||||
if payment != nil && payment.PaymentId != "" {
|
||||
return s.klarnaClient.UpdateOrder(r.Context(), payment.PaymentId, bytes.NewReader(payload))
|
||||
} else {
|
||||
return s.klarnaClient.CreateOrder(r.Context(), bytes.NewReader(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) ApplyKlarnaPaymentStarted(ctx context.Context, klarnaOrder *CheckoutOrder, id checkout.CheckoutId) (*actor.MutationResult[checkout.CheckoutGrain], error) {
|
||||
method := "checkout"
|
||||
return s.ApplyLocal(ctx, id, &messages.PaymentStarted{
|
||||
PaymentId: klarnaOrder.ID,
|
||||
Amount: int64(klarnaOrder.OrderAmount),
|
||||
Currency: klarnaOrder.PurchaseCurrency,
|
||||
Provider: "klarna",
|
||||
Method: &method,
|
||||
StartedAt: timestamppb.New(time.Now()),
|
||||
})
|
||||
}
|
||||
|
||||
var (
|
||||
tracer = otel.Tracer(name)
|
||||
hmacKey = os.Getenv("ADYEN_HMAC")
|
||||
meter = otel.Meter(name)
|
||||
logger = otelslog.NewLogger(name)
|
||||
proxyCalls metric.Int64Counter
|
||||
)
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
proxyCalls, err = meter.Int64Counter("proxy.calls",
|
||||
metric.WithDescription("Number of proxy calls"),
|
||||
metric.WithUnit("{calls}"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) GetAnywhere(ctx context.Context, checkoutId cart.CartId) (*checkout.CheckoutGrain, error) {
|
||||
id := uint64(checkoutId)
|
||||
if host, isOnOtherHost := s.OwnerHost(id); isOnOtherHost {
|
||||
return host.Get(ctx, id)
|
||||
}
|
||||
return s.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) ApplyAnywhere(ctx context.Context, checkoutId cart.CartId, msgs ...proto.Message) error {
|
||||
id := uint64(checkoutId)
|
||||
if host, isOnOtherHost := s.OwnerHost(id); isOnOtherHost {
|
||||
_, err := host.Apply(ctx, id, msgs...)
|
||||
return err
|
||||
}
|
||||
_, err := s.Apply(ctx, id, msgs...)
|
||||
return err
|
||||
}
|
||||
|
||||
type StartPayment struct {
|
||||
Provider string `json:"provider"`
|
||||
Method string `json:"method,omitempty"`
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) GetPaymentSessionHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
|
||||
paymentId := r.PathValue("id")
|
||||
grain, err := s.Get(r.Context(), uint64(checkoutId))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payment, ok := grain.FindPayment(paymentId)
|
||||
if !ok {
|
||||
http.Error(w, "payment not found", http.StatusNotFound)
|
||||
return nil
|
||||
}
|
||||
switch payment.Provider {
|
||||
case "adyen":
|
||||
payload := &SessionRequest{
|
||||
SessionResult: "",
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
service := s.adyenClient.Checkout()
|
||||
req := service.PaymentsApi.GetResultOfPaymentSessionInput(paymentId).SessionResult(payload.SessionResult)
|
||||
|
||||
res, _, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.Status != nil && *res.Status == "completed" {
|
||||
_, err := s.ApplyLocal(r.Context(), checkoutId, &messages.PaymentCompleted{
|
||||
PaymentId: paymentId,
|
||||
Status: *res.Status,
|
||||
ProcessorReference: res.Id,
|
||||
CompletedAt: timestamppb.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("unable to apply payment started mutation", "error", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return s.WriteResult(w, res)
|
||||
case "klarna":
|
||||
current, err := s.klarnaClient.GetOrder(r.Context(), paymentId)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
}
|
||||
return s.WriteResult(w, current)
|
||||
}
|
||||
|
||||
return errors.New("unsupported payment provider")
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) StartPaymentHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
|
||||
grain, err := s.Get(r.Context(), uint64(checkoutId))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := &StartPayment{}
|
||||
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
switch payload.Provider {
|
||||
case "adyen":
|
||||
meta := GetCheckoutMetaFromRequest(r)
|
||||
sessionData, err := BuildAdyenCheckoutSession(grain, meta)
|
||||
if err != nil {
|
||||
logger.Error("unable to build adyen session", "error", err)
|
||||
return err
|
||||
}
|
||||
service := s.adyenClient.Checkout()
|
||||
req := service.PaymentsApi.SessionsInput().CreateCheckoutSessionRequest(*sessionData)
|
||||
session, _, err := service.PaymentsApi.Sessions(r.Context(), req)
|
||||
if err != nil {
|
||||
logger.Error("unable to create adyen session", "error", err)
|
||||
return err
|
||||
}
|
||||
sessionBytes, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
logger.Error("unable to marshal session", "error", err)
|
||||
return err
|
||||
}
|
||||
// Apply PaymentStarted mutation
|
||||
result, err := s.ApplyLocal(r.Context(), checkoutId, &messages.PaymentStarted{
|
||||
PaymentId: session.Id,
|
||||
Amount: session.Amount.Value,
|
||||
Currency: session.Amount.Currency,
|
||||
Provider: "adyen",
|
||||
SessionData: &anypb.Any{
|
||||
TypeUrl: "type.googleapis.com/google.protobuf.StringValue",
|
||||
Value: sessionBytes,
|
||||
},
|
||||
Method: &payload.Method,
|
||||
StartedAt: timestamppb.New(time.Now()),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("unable to apply payment started mutation", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return s.WriteResult(w, result)
|
||||
|
||||
case "klarna":
|
||||
order, err := s.CreateOrUpdateCheckout(r, grain, nil)
|
||||
if err != nil {
|
||||
logger.Error("unable to create klarna session", "error", err)
|
||||
return err
|
||||
}
|
||||
orderBytes, err := json.Marshal(order)
|
||||
if err != nil {
|
||||
logger.Error("unable to marshal order", "error", err)
|
||||
return err
|
||||
}
|
||||
result, err := s.ApplyLocal(r.Context(), checkoutId, &messages.PaymentStarted{
|
||||
PaymentId: order.ID,
|
||||
Amount: int64(order.OrderAmount),
|
||||
Currency: order.PurchaseCurrency,
|
||||
Provider: "klarna",
|
||||
Method: &payload.Method,
|
||||
SessionData: &anypb.Any{
|
||||
TypeUrl: "type.googleapis.com/google.protobuf.StringValue",
|
||||
Value: orderBytes,
|
||||
},
|
||||
StartedAt: timestamppb.New(time.Now()),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("unable to apply payment started mutation", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return s.WriteResult(w, result)
|
||||
|
||||
default:
|
||||
http.Error(w, "unsupported payment provider", http.StatusBadRequest)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) {
|
||||
handleFunc := func(pattern string, handlerFunc func(http.ResponseWriter, *http.Request)) {
|
||||
attr := attribute.String("http.route", pattern)
|
||||
mux.HandleFunc(pattern, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
span := trace.SpanFromContext(r.Context())
|
||||
span.SetName(pattern)
|
||||
span.SetAttributes(attr)
|
||||
|
||||
labeler, _ := otelhttp.LabelerFromContext(r.Context())
|
||||
labeler.Add(attr)
|
||||
|
||||
handlerFunc(w, r)
|
||||
}))
|
||||
}
|
||||
//handleFunc("/payment/adyen/session", CookieCheckoutIdHandler(s.AdyenSessionHandler))
|
||||
handleFunc("/payment/adyen/push", s.AdyenHookHandler)
|
||||
handleFunc("/payment/adyen/return", s.AdyenReturnHandler)
|
||||
//handleFunc("/payment/adyen/cancel", s.AdyenCancelHandler)
|
||||
|
||||
handleFunc("/payment/klarna/validate", s.KlarnaValidationHandler)
|
||||
handleFunc("/payment/klarna/push", s.KlarnaPushHandler)
|
||||
handleFunc("/payment/klarna/notification", s.KlarnaNotificationHandler)
|
||||
|
||||
handleFunc("POST /api/checkout/start/{cartid}", s.StartCheckoutHandler)
|
||||
handleFunc("GET /api/checkout", CookieCheckoutIdHandler(s.ProxyHandler(s.GetCheckoutHandler)))
|
||||
handleFunc("POST /api/checkout/delivery", CookieCheckoutIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
|
||||
handleFunc("DELETE /api/checkout/delivery/{id}", CookieCheckoutIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
|
||||
handleFunc("POST /api/checkout/pickup-point", CookieCheckoutIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
|
||||
handleFunc("POST /api/checkout/contact-details", CookieCheckoutIdHandler(s.ProxyHandler(s.ContactDetailsUpdatedHandler)))
|
||||
handleFunc("POST /payment", CookieCheckoutIdHandler(s.ProxyHandler(s.StartPaymentHandler)))
|
||||
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/inventory-reserved", CookieCheckoutIdHandler(s.ProxyHandler(s.InventoryReservedHandler)))
|
||||
// handleFunc("POST /api/checkout/order-created", CookieCheckoutIdHandler(s.ProxyHandler(s.OrderCreatedHandler)))
|
||||
// handleFunc("POST /api/checkout/confirmation-viewed", CookieCheckoutIdHandler(s.ProxyHandler(s.ConfirmationViewedHandler)))
|
||||
|
||||
//handleFunc("GET /payment/klarna/session", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaSessionHandler)))
|
||||
//handleFunc("GET /payment/klarna/checkout", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaHtmlCheckoutHandler)))
|
||||
|
||||
handleFunc("GET /payment/klarna/confirmation/{order_id}", s.KlarnaConfirmationHandler)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
)
|
||||
|
||||
func getOriginalHost(r *http.Request) string {
|
||||
proxyHost := r.Header.Get("X-Forwarded-Host")
|
||||
if proxyHost != "" {
|
||||
return proxyHost
|
||||
}
|
||||
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 {
|
||||
ip := r.Header.Get("X-Forwarded-For")
|
||||
if ip == "" {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
func getCurrency(country string) string {
|
||||
if country == "no" {
|
||||
return "NOK"
|
||||
}
|
||||
return "SEK"
|
||||
}
|
||||
|
||||
func getLocale(country string) string {
|
||||
if country == "no" {
|
||||
return "nb-no"
|
||||
}
|
||||
return "sv-se"
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if strings.Contains(strings.ToLower(host), "-no") {
|
||||
return "no"
|
||||
}
|
||||
if strings.Contains(strings.ToLower(host), "-se") {
|
||||
return "se"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *CheckoutPoolServer) reserveInventory(ctx context.Context, grain *checkout.CheckoutGrain) error {
|
||||
if a.inventoryService != nil {
|
||||
inventoryRequests := getInventoryRequests(grain.CartState.Items)
|
||||
_, err := a.inventoryService.ReservationCheck(ctx, inventoryRequests...)
|
||||
if err != nil {
|
||||
logger.WarnContext(ctx, "placeorder inventory check failed")
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const checkoutCookieName = "checkoutid"
|
||||
|
||||
func setCheckoutCookie(w http.ResponseWriter, checkoutId checkout.CheckoutId, tls bool) {
|
||||
if checkoutId == 0 {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: checkoutCookieName,
|
||||
Value: checkoutId.String(),
|
||||
Secure: tls,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
Expires: time.Unix(0, 0),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
} else {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: checkoutCookieName,
|
||||
Value: checkoutId.String(),
|
||||
Secure: tls,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
Expires: time.Now().AddDate(0, 0, 14),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func CookieCheckoutIdHandler(fn func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var id checkout.CheckoutId
|
||||
cookie, err := r.Cookie(checkoutCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
w.WriteHeader(http.StatusNotAcceptable)
|
||||
return
|
||||
} else {
|
||||
parsed, ok := cart.ParseCartId(cookie.Value)
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotAcceptable)
|
||||
return
|
||||
} else {
|
||||
id = parsed
|
||||
}
|
||||
}
|
||||
|
||||
err = fn(w, r, id)
|
||||
if err != nil {
|
||||
log.Printf("Server error, not remote error: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error) func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
|
||||
return func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
|
||||
if ownerHost, ok := s.OwnerHost(uint64(checkoutId)); ok {
|
||||
ctx, span := tracer.Start(r.Context(), "proxy")
|
||||
defer span.End()
|
||||
span.SetAttributes(attribute.String("checkoutid", checkoutId.String()))
|
||||
hostAttr := attribute.String("other host", ownerHost.Name())
|
||||
span.SetAttributes(hostAttr)
|
||||
logger.InfoContext(ctx, "checkout proxyed", "result", ownerHost.Name())
|
||||
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
|
||||
handled, err := ownerHost.Proxy(uint64(checkoutId), w, r, nil)
|
||||
|
||||
grainLookups.Inc()
|
||||
if err == nil && handled {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
_, span := tracer.Start(r.Context(), "own")
|
||||
span.SetAttributes(attribute.String("checkoutid", checkoutId.String()))
|
||||
defer span.End()
|
||||
return fn(w, r, checkoutId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||
"git.k6n.net/mats/slask-finder/pkg/index"
|
||||
"git.k6n.net/mats/slask-finder/pkg/messaging"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/redis/go-redis/v9/maintnotifications"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
inventoryService *inventory.RedisInventoryService
|
||||
reservationService *inventory.RedisCartReservationService
|
||||
}
|
||||
|
||||
func (srv *Server) livezHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
}
|
||||
|
||||
func (srv *Server) readyzHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
}
|
||||
|
||||
func (srv *Server) getInventoryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
sku := inventory.SKU(r.PathValue("sku"))
|
||||
locationID := inventory.LocationID(r.PathValue("locationId"))
|
||||
|
||||
quantity, err := srv.inventoryService.GetInventory(r.Context(), sku, locationID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]int64{"quantity": quantity}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func (srv *Server) getReservationHandler(w http.ResponseWriter, r *http.Request) {
|
||||
sku := inventory.SKU(r.PathValue("sku"))
|
||||
locationID := inventory.LocationID(r.PathValue("locationId"))
|
||||
|
||||
summary, err := srv.reservationService.GetReservationSummary(r.Context(), sku, locationID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(summary)
|
||||
}
|
||||
|
||||
var country = "se"
|
||||
var redisAddress = "10.10.3.18:6379"
|
||||
var redisPassword = "slaskredis"
|
||||
|
||||
func init() {
|
||||
// Override redis config from environment variables if set
|
||||
if addr, ok := os.LookupEnv("REDIS_ADDRESS"); ok {
|
||||
redisAddress = addr
|
||||
}
|
||||
if password, ok := os.LookupEnv("REDIS_PASSWORD"); ok {
|
||||
redisPassword = password
|
||||
}
|
||||
if ctry, ok := os.LookupEnv("COUNTRY"); ok {
|
||||
country = ctry
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
var ctx = context.Background()
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: redisAddress,
|
||||
Password: redisPassword, // no password set
|
||||
DB: 0, // use default DB
|
||||
MaintNotificationsConfig: &maintnotifications.Config{
|
||||
Mode: maintnotifications.ModeDisabled,
|
||||
},
|
||||
})
|
||||
s, err := inventory.NewRedisInventoryService(rdb)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to connect to inventory redis: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
r, err := inventory.NewRedisCartReservationService(rdb)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to connect to reservation redis: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
server := &Server{inventoryService: s, reservationService: r}
|
||||
|
||||
// Set up HTTP routes
|
||||
http.HandleFunc("/livez", server.livezHandler)
|
||||
http.HandleFunc("/readyz", server.readyzHandler)
|
||||
http.HandleFunc("/inventory/{sku}/{locationId}", server.getInventoryHandler)
|
||||
http.HandleFunc("/reservations/{sku}/{locationId}", server.getReservationHandler)
|
||||
|
||||
stockhandler := &StockHandler{
|
||||
MainStockLocationID: inventory.LocationID(country),
|
||||
rdb: rdb,
|
||||
ctx: ctx,
|
||||
svc: *s,
|
||||
}
|
||||
|
||||
amqpUrl, ok := os.LookupEnv("RABBIT_HOST")
|
||||
if ok {
|
||||
log.Printf("Connecting to rabbitmq")
|
||||
conn, err := amqp.DialConfig(amqpUrl, amqp.Config{
|
||||
Properties: amqp.NewConnectionProperties(),
|
||||
})
|
||||
//a.conn = conn
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to RabbitMQ: %v", err)
|
||||
}
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open a channel: %v", err)
|
||||
}
|
||||
// items listener
|
||||
err = messaging.ListenToTopic(ch, country, "item_added", func(d amqp.Delivery) error {
|
||||
wg := &sync.WaitGroup{}
|
||||
err = index.ForEachRawDataItemInJSONArray(d.Body, func(item *index.RawDataItem) error {
|
||||
stockhandler.HandleItem(item, wg)
|
||||
wg.Wait()
|
||||
log.Print("Batch done...")
|
||||
return err
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to listen to item_added topic: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Start HTTP server
|
||||
log.Println("Starting HTTP server on :8080")
|
||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||
"git.k6n.net/mats/slask-finder/pkg/types"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type StockHandler struct {
|
||||
rdb *redis.Client
|
||||
ctx context.Context
|
||||
svc inventory.RedisInventoryService
|
||||
MainStockLocationID inventory.LocationID
|
||||
}
|
||||
|
||||
func (s *StockHandler) HandleItem(item types.Item, wg *sync.WaitGroup) {
|
||||
wg.Go(func() {
|
||||
ctx := s.ctx
|
||||
pipe := s.rdb.Pipeline()
|
||||
centralStock, ok := item.GetNumberFieldValue("inStock")
|
||||
if !ok {
|
||||
centralStock = 0
|
||||
}
|
||||
|
||||
sku, ok := item.GetStringFieldValue("sku")
|
||||
if !ok {
|
||||
log.Printf("unable to parse central stock for item %s: %v", sku)
|
||||
centralStock = 0
|
||||
} else {
|
||||
s.svc.UpdateInventory(ctx, pipe, inventory.SKU(sku), s.MainStockLocationID, int64(centralStock))
|
||||
}
|
||||
// for id, value := range item.GetStock() {
|
||||
// s.svc.UpdateInventory(ctx, pipe, inventory.SKU(sku), inventory.LocationID(id), int64(value))
|
||||
// }
|
||||
_, err := pipe.Exec(ctx)
|
||||
if err != nil {
|
||||
log.Printf("unable to update stock: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
// 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()})
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
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
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,37 @@
|
||||
{"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"}}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"version": 1,
|
||||
"state": {
|
||||
"promotions": [
|
||||
{
|
||||
"id": "volymrabatt",
|
||||
"name": "Volymrabatt",
|
||||
"description": "Automatisk volymrabatt baserad på varukorgens totalsumma (inkl. moms). Belopp i öre: 20 000–40 000 kr → 5%, 40 000–60 000 kr → 9%, 60 000–90 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 5–14%"
|
||||
}
|
||||
],
|
||||
"usageCount": 0,
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"updatedAt": "2024-01-01T00:00:00Z",
|
||||
"createdBy": "mats.tornberg@gmail.com",
|
||||
"tags": ["volume", "volymrabatt", "test"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
+422
-13
@@ -9,6 +9,110 @@ type: Opaque
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
app: cart-backoffice
|
||||
arch: amd64
|
||||
name: cart-backoffice-x86
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: cart-backoffice
|
||||
arch: amd64
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: cart-backoffice
|
||||
arch: amd64
|
||||
spec:
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/arch
|
||||
operator: NotIn
|
||||
values:
|
||||
- arm64
|
||||
volumes:
|
||||
- name: data
|
||||
nfs:
|
||||
path: /i-data/7a8af061/nfs/
|
||||
server: 10.10.1.10
|
||||
serviceAccountName: default
|
||||
containers:
|
||||
- image: registry.k6n.net/go-cart-actor-amd64:latest
|
||||
name: cart-actor-amd64
|
||||
imagePullPolicy: Always
|
||||
command: ["/go-cart-backoffice"]
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command: ["sleep", "15"]
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: web
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /livez
|
||||
port: web
|
||||
failureThreshold: 1
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: web
|
||||
failureThreshold: 2
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 30
|
||||
volumeMounts:
|
||||
- mountPath: "/data"
|
||||
name: data
|
||||
resources:
|
||||
limits:
|
||||
memory: "768Mi"
|
||||
requests:
|
||||
memory: "70Mi"
|
||||
cpu: "1200m"
|
||||
env:
|
||||
- name: CART_DIR
|
||||
value: "/data/cart-actor"
|
||||
- name: CHECKOUT_DIR
|
||||
value: "/data/checkout-actor"
|
||||
- name: TZ
|
||||
value: "Europe/Stockholm"
|
||||
- name: REDIS_ADDRESS
|
||||
value: "10.10.3.18:6379"
|
||||
- name: REDIS_PASSWORD
|
||||
value: "slaskredis"
|
||||
- name: ADYEN_HMAC
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: adyen
|
||||
key: HMAC
|
||||
- name: ADYEN_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: adyen
|
||||
key: API_KEY
|
||||
- name: KLARNA_API_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: klarna-api-credentials
|
||||
key: username
|
||||
- name: KLARNA_API_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: klarna-api-credentials
|
||||
key: password
|
||||
- name: AMQP_URL
|
||||
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
|
||||
# - name: BASE_URL
|
||||
# value: "https://s10n-no.tornberg.me"
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
app: cart-actor
|
||||
@@ -41,11 +145,9 @@ spec:
|
||||
nfs:
|
||||
path: /i-data/7a8af061/nfs/cart-actor
|
||||
server: 10.10.1.10
|
||||
imagePullSecrets:
|
||||
- name: regcred
|
||||
serviceAccountName: default
|
||||
containers:
|
||||
- image: registry.knatofs.se/go-cart-actor-amd64:latest
|
||||
- image: registry.k6n.net/go-cart-actor-amd64:latest
|
||||
name: cart-actor-amd64
|
||||
imagePullPolicy: Always
|
||||
lifecycle:
|
||||
@@ -55,6 +157,8 @@ spec:
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: web
|
||||
- containerPort: 8081
|
||||
name: debug
|
||||
- containerPort: 1337
|
||||
name: rpc
|
||||
livenessProbe:
|
||||
@@ -62,14 +166,14 @@ spec:
|
||||
path: /livez
|
||||
port: web
|
||||
failureThreshold: 1
|
||||
periodSeconds: 10
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: web
|
||||
failureThreshold: 2
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 10
|
||||
periodSeconds: 50
|
||||
volumeMounts:
|
||||
- mountPath: "/data"
|
||||
name: data
|
||||
@@ -80,6 +184,10 @@ spec:
|
||||
memory: "70Mi"
|
||||
cpu: "1200m"
|
||||
env:
|
||||
- name: CART_DIR
|
||||
value: "/data/cart-actor"
|
||||
- name: CHECKOUT_DIR
|
||||
value: "/data/checkout-actor"
|
||||
- name: TZ
|
||||
value: "Europe/Stockholm"
|
||||
- name: KLARNA_API_USERNAME
|
||||
@@ -87,6 +195,24 @@ spec:
|
||||
secretKeyRef:
|
||||
name: klarna-api-credentials
|
||||
key: username
|
||||
- name: REDIS_ADDRESS
|
||||
value: "10.10.3.18:6379"
|
||||
- name: REDIS_PASSWORD
|
||||
value: "slaskredis"
|
||||
- name: OTEL_RESOURCE_ATTRIBUTES
|
||||
value: "service.name=cart,service.version=0.1.2"
|
||||
- name: OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
value: "http://otel-debug-service.monitoring:4317"
|
||||
- name: ADYEN_HMAC
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: adyen
|
||||
key: HMAC
|
||||
- name: ADYEN_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: adyen
|
||||
key: API_KEY
|
||||
- name: KLARNA_API_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
@@ -143,11 +269,9 @@ spec:
|
||||
nfs:
|
||||
path: /i-data/7a8af061/nfs/cart-actor
|
||||
server: 10.10.1.10
|
||||
imagePullSecrets:
|
||||
- name: regcred
|
||||
serviceAccountName: default
|
||||
containers:
|
||||
- image: registry.knatofs.se/go-cart-actor:latest
|
||||
- image: registry.k6n.net/go-cart-actor:latest
|
||||
name: cart-actor-arm64
|
||||
imagePullPolicy: Always
|
||||
lifecycle:
|
||||
@@ -157,6 +281,8 @@ spec:
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: web
|
||||
- containerPort: 8081
|
||||
name: debug
|
||||
- containerPort: 1337
|
||||
name: rpc
|
||||
livenessProbe:
|
||||
@@ -164,14 +290,14 @@ spec:
|
||||
path: /livez
|
||||
port: web
|
||||
failureThreshold: 1
|
||||
periodSeconds: 10
|
||||
periodSeconds: 15
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: web
|
||||
failureThreshold: 2
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 10
|
||||
periodSeconds: 15
|
||||
volumeMounts:
|
||||
- mountPath: "/data"
|
||||
name: data
|
||||
@@ -184,6 +310,24 @@ spec:
|
||||
env:
|
||||
- name: TZ
|
||||
value: "Europe/Stockholm"
|
||||
- name: REDIS_ADDRESS
|
||||
value: "redis.home:6379"
|
||||
- name: REDIS_PASSWORD
|
||||
value: "slaskredis"
|
||||
- name: OTEL_RESOURCE_ATTRIBUTES
|
||||
value: "service.name=cart,service.version=0.1.2"
|
||||
- name: OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
value: "http://otel-debug-service.monitoring:4317"
|
||||
- name: ADYEN_HMAC
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: adyen
|
||||
key: HMAC
|
||||
- name: ADYEN_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: adyen
|
||||
key: API_KEY
|
||||
- name: KLARNA_API_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
@@ -212,7 +356,7 @@ apiVersion: v1
|
||||
metadata:
|
||||
name: cart-actor
|
||||
annotations:
|
||||
prometheus.io/port: "8080"
|
||||
prometheus.io/port: "8081"
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/path: "/metrics"
|
||||
spec:
|
||||
@@ -221,6 +365,151 @@ spec:
|
||||
ports:
|
||||
- name: web
|
||||
port: 8080
|
||||
- name: internal
|
||||
port: 8081
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
app: checkout-actor
|
||||
arch: amd64
|
||||
name: checkout-actor-x86
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: checkout-actor
|
||||
arch: amd64
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: checkout-actor
|
||||
actor-pool: checkout
|
||||
arch: amd64
|
||||
spec:
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/arch
|
||||
operator: NotIn
|
||||
values:
|
||||
- arm64
|
||||
volumes:
|
||||
- name: data
|
||||
nfs:
|
||||
path: /i-data/7a8af061/nfs/checkout-actor
|
||||
server: 10.10.1.10
|
||||
serviceAccountName: default
|
||||
containers:
|
||||
- image: registry.k6n.net/go-cart-actor-amd64:latest
|
||||
name: checkout-actor-amd64
|
||||
imagePullPolicy: Always
|
||||
command: ["/go-checkout-actor"]
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command: ["sleep", "15"]
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: web
|
||||
- containerPort: 8081
|
||||
name: debug
|
||||
- containerPort: 1337
|
||||
name: rpc
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /livez
|
||||
port: web
|
||||
failureThreshold: 1
|
||||
periodSeconds: 15
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: web
|
||||
failureThreshold: 2
|
||||
initialDelaySeconds: 1
|
||||
periodSeconds: 15
|
||||
volumeMounts:
|
||||
- mountPath: "/data"
|
||||
name: data
|
||||
resources:
|
||||
limits:
|
||||
memory: "768Mi"
|
||||
requests:
|
||||
memory: "70Mi"
|
||||
cpu: "1200m"
|
||||
env:
|
||||
- name: TZ
|
||||
value: "Europe/Stockholm"
|
||||
- name: REDIS_ADDRESS
|
||||
value: "10.10.3.18:6379"
|
||||
- name: REDIS_PASSWORD
|
||||
value: "slaskredis"
|
||||
- name: OTEL_RESOURCE_ATTRIBUTES
|
||||
value: "service.name=checkout,service.version=0.1.2"
|
||||
- name: OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
value: "http://otel-debug-service.monitoring:4317"
|
||||
- name: ADYEN_HMAC
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: adyen
|
||||
key: HMAC
|
||||
- name: ADYEN_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: adyen
|
||||
key: API_KEY
|
||||
- name: KLARNA_API_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: klarna-api-credentials
|
||||
key: username
|
||||
- name: KLARNA_API_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: klarna-api-credentials
|
||||
key: password
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.podIP
|
||||
- name: AMQP_URL
|
||||
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
|
||||
- name: CART_INTERNAL_URL
|
||||
value: "http://cart-actor:8080"
|
||||
- name: POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
---
|
||||
kind: Service
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: checkout-actor
|
||||
annotations:
|
||||
prometheus.io/port: "8081"
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/path: "/metrics"
|
||||
spec:
|
||||
selector:
|
||||
app: checkout-actor
|
||||
ports:
|
||||
- name: web
|
||||
port: 8080
|
||||
---
|
||||
kind: Service
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: cart-backoffice
|
||||
spec:
|
||||
selector:
|
||||
app: cart-backoffice
|
||||
ports:
|
||||
- name: web
|
||||
port: 8080
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
@@ -237,10 +526,10 @@ spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- cart.tornberg.me
|
||||
- cart.k6n.net
|
||||
secretName: cart-actor-tls-secret
|
||||
rules:
|
||||
- host: cart.tornberg.me
|
||||
- host: cart.k6n.net
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
@@ -250,3 +539,123 @@ spec:
|
||||
name: cart-actor
|
||||
port:
|
||||
number: 8080
|
||||
- path: /api/checkout
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: checkout-actor
|
||||
port:
|
||||
number: 8080
|
||||
- path: /payment
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: checkout-actor
|
||||
port:
|
||||
number: 8080
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: cart-backend-ingress
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- slask-cart.k6n.net
|
||||
secretName: cart-backoffice-actor-tls-secret
|
||||
rules:
|
||||
- host: slask-cart.k6n.net
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: cart-backoffice
|
||||
port:
|
||||
number: 8080
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
app: cart-inventory
|
||||
arch: amd64
|
||||
name: cart-inventory-x86
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: cart-inventory
|
||||
arch: amd64
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: cart-inventory
|
||||
arch: amd64
|
||||
spec:
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/arch
|
||||
operator: NotIn
|
||||
values:
|
||||
- arm64
|
||||
serviceAccountName: default
|
||||
containers:
|
||||
- image: registry.k6n.net/go-cart-actor-amd64:latest
|
||||
name: cart-inventory-amd64
|
||||
imagePullPolicy: Always
|
||||
command: ["/go-cart-inventory"]
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command: ["sleep", "15"]
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: web
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /livez
|
||||
port: web
|
||||
failureThreshold: 1
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: web
|
||||
failureThreshold: 2
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 30
|
||||
resources:
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
cpu: "500m"
|
||||
requests:
|
||||
memory: "50Mi"
|
||||
cpu: "500m"
|
||||
env:
|
||||
- name: TZ
|
||||
value: "Europe/Stockholm"
|
||||
- name: RABBIT_HOST
|
||||
value: amqp://admin:12bananer@rabbitmq.s10n:5672/
|
||||
- name: REDIS_ADDRESS
|
||||
value: "redis.home:6379"
|
||||
- name: REDIS_PASSWORD
|
||||
value: "slaskredis"
|
||||
---
|
||||
kind: Service
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: inventory
|
||||
spec:
|
||||
selector:
|
||||
app: cart-inventory
|
||||
ports:
|
||||
- name: web
|
||||
port: 8080
|
||||
|
||||
@@ -1,91 +1,115 @@
|
||||
module git.tornberg.me/go-cart-actor
|
||||
module git.k6n.net/mats/go-cart-actor
|
||||
|
||||
go 1.25.1
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/gogo/protobuf v1.3.2
|
||||
github.com/adyen/adyen-go-api-library/v21 v21.1.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/rabbitmq/amqp091-go v1.10.0
|
||||
google.golang.org/grpc v1.76.0
|
||||
google.golang.org/protobuf v1.36.10
|
||||
k8s.io/api v0.34.1
|
||||
k8s.io/apimachinery v0.34.1
|
||||
k8s.io/client-go v0.34.1
|
||||
github.com/rabbitmq/amqp091-go v1.11.0
|
||||
github.com/redis/go-redis/v9 v9.20.0
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0
|
||||
go.opentelemetry.io/otel v1.44.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0
|
||||
go.opentelemetry.io/otel/log v0.20.0
|
||||
go.opentelemetry.io/otel/metric v1.44.0
|
||||
go.opentelemetry.io/otel/sdk v1.44.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0
|
||||
go.opentelemetry.io/otel/trace v1.44.0
|
||||
google.golang.org/grpc v1.81.1
|
||||
google.golang.org/protobuf v1.36.11
|
||||
k8s.io/api v0.34.2
|
||||
k8s.io/apimachinery v0.34.2
|
||||
k8s.io/client-go v0.34.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/RoaringBitmap/roaring/v2 v2.10.0 // indirect
|
||||
github.com/RoaringBitmap/roaring/v2 v2.18.2 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.0 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||
github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/getkin/kin-openapi v0.132.0 // indirect
|
||||
github.com/getkin/kin-openapi v0.133.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.1 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.2 // indirect
|
||||
github.com/go-openapi/swag v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/cmdutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/fileutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/mangling v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/netutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
|
||||
github.com/google/gnostic-models v0.7.0 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.3 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.3 // indirect
|
||||
github.com/go-openapi/swag v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/fileutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/mangling v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/netutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/gnostic-models v0.7.1 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/gorilla/schema v1.4.1 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/mschoch/smat v0.2.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 // indirect
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 // indirect
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
|
||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.1 // indirect
|
||||
github.com/prometheus/procfs v0.17.0 // indirect
|
||||
github.com/speakeasy-api/jsonpath v0.6.0 // indirect
|
||||
github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/prometheus/common v0.68.0 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/speakeasy-api/jsonpath v0.6.2 // indirect
|
||||
github.com/speakeasy-api/openapi-overlay v0.10.3 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||
github.com/woodsbury/decimal128 v1.4.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/mod v0.28.0 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/oauth2 v0.32.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/term v0.36.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.37.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff // indirect
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/term v0.43.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
k8s.io/klog/v2 v2.130.1 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e // indirect
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
|
||||
|
||||
@@ -1,67 +1,78 @@
|
||||
github.com/RoaringBitmap/roaring/v2 v2.10.0 h1:HbJ8Cs71lfCJyvmSptxeMX2PtvOC8yonlU0GQcy2Ak0=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.10.0/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.18.2 h1:oPq3Cgx//iDuJQVp6xSInAKW34J9CEwE5GmLI2z+Eic=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.18.2/go.mod h1:eq4wdNXxtJIS/oikeCzdX1rBzek7ANzbth041hrU8Q4=
|
||||
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/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM=
|
||||
github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
|
||||
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
|
||||
github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589 h1:VJ/jVUWr+r4MQA7U/cscbbXRuwh1PfPCUUItYAjlKN4=
|
||||
github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589/go.mod h1:IeI20psFPeg2n1jxwbkYCmkpYsXsJqB7qmoqCIlX80s=
|
||||
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
|
||||
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
|
||||
github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
|
||||
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
||||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
|
||||
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
|
||||
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
|
||||
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
|
||||
github.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8=
|
||||
github.com/go-openapi/swag v0.25.1/go.mod h1:bzONdGlT0fkStgGPd3bhZf1MnuPkf2YAys6h+jZipOo=
|
||||
github.com/go-openapi/swag/cmdutils v0.25.1 h1:nDke3nAFDArAa631aitksFGj2omusks88GF1VwdYqPY=
|
||||
github.com/go-openapi/swag/cmdutils v0.25.1/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
|
||||
github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
|
||||
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
|
||||
github.com/go-openapi/swag/fileutils v0.25.1 h1:rSRXapjQequt7kqalKXdcpIegIShhTPXx7yw0kek2uU=
|
||||
github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M=
|
||||
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
|
||||
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
|
||||
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
|
||||
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
|
||||
github.com/go-openapi/swag/mangling v0.25.1 h1:XzILnLzhZPZNtmxKaz/2xIGPQsBsvmCjrJOWGNz/ync=
|
||||
github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ=
|
||||
github.com/go-openapi/swag/netutils v0.25.1 h1:2wFLYahe40tDUHfKT1GRC4rfa5T1B4GWZ+msEFA4Fl4=
|
||||
github.com/go-openapi/swag/netutils v0.25.1/go.mod h1:CAkkvqnUJX8NV96tNhEQvKz8SQo2KF0f7LleiJwIeRE=
|
||||
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
|
||||
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
|
||||
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
|
||||
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
|
||||
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
|
||||
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
|
||||
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
|
||||
github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU=
|
||||
github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ=
|
||||
github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4=
|
||||
github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
|
||||
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
|
||||
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
|
||||
github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y=
|
||||
github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
|
||||
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
|
||||
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
|
||||
github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48=
|
||||
github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg=
|
||||
github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0=
|
||||
github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
|
||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||
@@ -69,34 +80,24 @@ github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncV
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
||||
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
|
||||
github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
|
||||
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
||||
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
@@ -105,6 +106,8 @@ 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/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/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.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
@@ -114,10 +117,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548 h1:lkxVz5lNPlU78El49cx1c3Rxo/bp7Gbzp2BjSRFgg1U=
|
||||
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548/go.mod h1:k4lo5gFYb3AqrgftlraCnv95xvjB/w8udRqJQJ12mAE=
|
||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
git.k6n.net/mats/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=
|
||||
git.k6n.net/mats/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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -130,169 +135,172 @@ github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
|
||||
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 h1:iJvF8SdB/3/+eGOXEpsWkD8FQAHj6mqkb6Fnsoc8MFU=
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0/go.mod h1:fwlMxUEMuQK5ih9aymrxKPQqNm2n8bdLk1ppjH+lr9w=
|
||||
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
|
||||
github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 h1:5vHNY1uuPBRBWqB2Dp0G7YB03phxLQZupZTIZaeorjc=
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1/go.mod h1:ro0npU1BWkcGpCgGD9QwPp44l5OIZ94tB3eabnT7DjQ=
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
|
||||
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
||||
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
|
||||
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
||||
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
|
||||
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
||||
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
|
||||
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
|
||||
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
|
||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI=
|
||||
github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q=
|
||||
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/prometheus/common v0.68.0 h1:8rQJvQmYltsR2L7h8Zw0Iyj8WYNNmpwikoQTZXwfVeA=
|
||||
github.com/prometheus/common v0.68.0/go.mod h1:4soH+U8yJSROk7OJ//hmTiWKsxapv6zRGgTt3keN8gQ=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/rabbitmq/amqp091-go v1.11.0 h1:HxIctVm9Gid/Vtn706necmZ7Wj6pgGI2eqplRbEY8O8=
|
||||
github.com/rabbitmq/amqp091-go v1.11.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.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g8DHMXJ8=
|
||||
github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw=
|
||||
github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU=
|
||||
github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/speakeasy-api/jsonpath v0.6.2 h1:Mys71yd6u8kuowNCR0gCVPlVAHCmKtoGXYoAtcEbqXQ=
|
||||
github.com/speakeasy-api/jsonpath v0.6.2/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw=
|
||||
github.com/speakeasy-api/openapi-overlay v0.10.3 h1:70een4vwHyslIp796vM+ox6VISClhtXsCjrQNhxwvWs=
|
||||
github.com/speakeasy-api/openapi-overlay v0.10.3/go.mod h1:RJjV0jbUHqXLS0/Mxv5XE7LAnJHqHw+01RDdpoGqiyY=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
|
||||
github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc=
|
||||
github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
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/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/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.19.0/go.mod h1:iTBIdNwx/xmUhfgJs6+84S4dIK059811cO1eUBjKcHY=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
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.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
|
||||
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/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
|
||||
go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs=
|
||||
go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY=
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0 h1:OqdRZ1guyzamK3M6LlRsmGqRrjkHWw6WZOKKli5ELpg=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0/go.mod h1:PuMIlm7zAt7c3z8zfOI5ox4iT1Z87We+PF6YoINux/M=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
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/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff h1:A90eA31Wq6HOMIQlLfzFwzqGKBTuaVztYu/g8sn+8Zc=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
@@ -307,31 +315,29 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM=
|
||||
k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk=
|
||||
k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4=
|
||||
k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
|
||||
k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY=
|
||||
k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8=
|
||||
k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY=
|
||||
k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw=
|
||||
k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4=
|
||||
k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
|
||||
k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M=
|
||||
k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE=
|
||||
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
|
||||
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ=
|
||||
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
|
||||
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
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=
|
||||
@@ -0,0 +1,95 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
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})
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
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+`"`)
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
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},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,681 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
// 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
-7
@@ -18,10 +18,10 @@ This directory contains a k6 script (`cart_load_test.js`) to stress and observe
|
||||
|
||||
The script exercises (per iteration):
|
||||
|
||||
1. `GET /cart/` – ensure / fetch cart state (creates cart if missing; sets `cartid` & `cartowner` cookies)
|
||||
2. `POST /cart/` – add item mutation (random SKU & quantity)
|
||||
3. `GET /cart/` – fetch after mutations
|
||||
4. `GET /cart/checkout` – occasionally (~2% of iterations) to simulate checkout start
|
||||
1. `GET /cart` – ensure / fetch cart state (creates cart if missing; sets `cartid` & `cartowner` cookies)
|
||||
2. `POST /cart` – add item mutation (random SKU & quantity)
|
||||
3. `GET /cart` – fetch after mutations
|
||||
4. `GET /checkout` – occasionally (~2% of iterations) to simulate checkout start
|
||||
|
||||
You can extend it easily to hit deliveries, quantity changes, or removal endpoints.
|
||||
|
||||
@@ -40,9 +40,9 @@ Example run:
|
||||
|
||||
```bash
|
||||
k6 run \
|
||||
-e BASE_URL=https://cart.prod.example.com/cart \
|
||||
-e BASE_URL=https://cart.k6n.net/cart \
|
||||
-e VUS=40 \
|
||||
-e DURATION=10m \
|
||||
-e DURATION=1m \
|
||||
-e RAMP_TARGET=120 \
|
||||
k6/cart_load_test.js
|
||||
```
|
||||
@@ -171,4 +171,4 @@ Feel free to request:
|
||||
- WebSocket / long poll integration (if added later)
|
||||
- Synthetic error injection harness
|
||||
|
||||
Happy load testing!
|
||||
Happy load testing!
|
||||
|
||||
+166
-170
@@ -4,43 +4,39 @@ import { Counter, Trend } from "k6/metrics";
|
||||
|
||||
// ---------------- Configuration ----------------
|
||||
export const options = {
|
||||
// Adjust vus/duration for your environment
|
||||
scenarios: {
|
||||
steady_mutations: {
|
||||
executor: "constant-vus",
|
||||
vus: __ENV.VUS ? parseInt(__ENV.VUS, 10) : 20,
|
||||
duration: __ENV.DURATION || "5m",
|
||||
gracefulStop: "30s",
|
||||
},
|
||||
ramp_up: {
|
||||
executor: "ramping-vus",
|
||||
startVUs: 0,
|
||||
stages: [
|
||||
{
|
||||
duration: "1m",
|
||||
target: __ENV.RAMP_TARGET
|
||||
? parseInt(__ENV.RAMP_TARGET, 10)
|
||||
: 50,
|
||||
},
|
||||
{
|
||||
duration: "1m",
|
||||
target: __ENV.RAMP_TARGET
|
||||
? parseInt(__ENV.RAMP_TARGET, 10)
|
||||
: 50,
|
||||
},
|
||||
{ duration: "1m", target: 0 },
|
||||
],
|
||||
gracefulStop: "30s",
|
||||
startTime: "30s",
|
||||
},
|
||||
// Adjust vus/duration for your environment
|
||||
scenarios: {
|
||||
steady_mutations: {
|
||||
executor: "constant-vus",
|
||||
vus: __ENV.VUS ? parseInt(__ENV.VUS, 10) : 20,
|
||||
duration: __ENV.DURATION || "5m",
|
||||
gracefulStop: "30s",
|
||||
},
|
||||
thresholds: {
|
||||
http_req_failed: ["rate<0.02"], // < 2% failures
|
||||
http_req_duration: ["p(90)<800", "p(99)<1500"], // latency SLO
|
||||
"cart_add_item_duration{op:add}": ["p(90)<800"],
|
||||
"cart_fetch_duration{op:get}": ["p(90)<600"],
|
||||
ramp_up: {
|
||||
executor: "ramping-vus",
|
||||
startVUs: 0,
|
||||
stages: [
|
||||
{
|
||||
duration: "1m",
|
||||
target: __ENV.RAMP_TARGET ? parseInt(__ENV.RAMP_TARGET, 10) : 50,
|
||||
},
|
||||
{
|
||||
duration: "1m",
|
||||
target: __ENV.RAMP_TARGET ? parseInt(__ENV.RAMP_TARGET, 10) : 50,
|
||||
},
|
||||
{ duration: "1m", target: 0 },
|
||||
],
|
||||
gracefulStop: "30s",
|
||||
startTime: "30s",
|
||||
},
|
||||
summaryTrendStats: ["avg", "min", "med", "max", "p(90)", "p(95)", "p(99)"],
|
||||
},
|
||||
thresholds: {
|
||||
http_req_failed: ["rate<0.02"], // < 2% failures
|
||||
http_req_duration: ["p(90)<800", "p(99)<1500"], // latency SLO
|
||||
"cart_add_item_duration{op:add}": ["p(90)<800"],
|
||||
"cart_fetch_duration{op:get}": ["p(90)<600"],
|
||||
},
|
||||
summaryTrendStats: ["avg", "min", "med", "max", "p(90)", "p(95)", "p(99)"],
|
||||
};
|
||||
|
||||
// ---------------- Metrics ----------------
|
||||
@@ -52,197 +48,197 @@ const checkoutCounter = new Counter("cart_checkout_calls");
|
||||
|
||||
// ---------------- SKUs ----------------
|
||||
const SKUS = [
|
||||
"778290",
|
||||
"778345",
|
||||
"778317",
|
||||
"778277",
|
||||
"778267",
|
||||
"778376",
|
||||
"778244",
|
||||
"778384",
|
||||
"778365",
|
||||
"778377",
|
||||
"778255",
|
||||
"778286",
|
||||
"778246",
|
||||
"778270",
|
||||
"778266",
|
||||
"778285",
|
||||
"778329",
|
||||
"778425",
|
||||
"778407",
|
||||
"778418",
|
||||
"778430",
|
||||
"778469",
|
||||
"778358",
|
||||
"778351",
|
||||
"778319",
|
||||
"778307",
|
||||
"778278",
|
||||
"778251",
|
||||
"778253",
|
||||
"778261",
|
||||
"778263",
|
||||
"778273",
|
||||
"778281",
|
||||
"778294",
|
||||
"778297",
|
||||
"778302",
|
||||
"778290",
|
||||
"778345",
|
||||
"778317",
|
||||
"778277",
|
||||
"778267",
|
||||
"778376",
|
||||
"778244",
|
||||
"778384",
|
||||
"778365",
|
||||
"778377",
|
||||
"778255",
|
||||
"778286",
|
||||
"778246",
|
||||
"778270",
|
||||
"778266",
|
||||
"778285",
|
||||
"778329",
|
||||
"778425",
|
||||
"778407",
|
||||
"778418",
|
||||
"778430",
|
||||
"778469",
|
||||
"778358",
|
||||
"778351",
|
||||
"778319",
|
||||
"778307",
|
||||
"778278",
|
||||
"778251",
|
||||
"778253",
|
||||
"778261",
|
||||
"778263",
|
||||
"778273",
|
||||
"778281",
|
||||
"778294",
|
||||
"778297",
|
||||
"778302",
|
||||
];
|
||||
|
||||
// ---------------- Helpers ----------------
|
||||
function randomSku() {
|
||||
return SKUS[Math.floor(Math.random() * SKUS.length)];
|
||||
return SKUS[Math.floor(Math.random() * SKUS.length)];
|
||||
}
|
||||
function randomQty() {
|
||||
return 1 + Math.floor(Math.random() * 3); // 1..3
|
||||
return 1 + Math.floor(Math.random() * 3); // 1..3
|
||||
}
|
||||
function baseUrl() {
|
||||
const u = __ENV.BASE_URL || "http://localhost:8080/cart";
|
||||
// Allow user to pass either root host or full /cart path
|
||||
return u.endsWith("/cart") ? u : u.replace(/\/+$/, "") + "/cart";
|
||||
const u = __ENV.BASE_URL || "http://localhost:8080/cart";
|
||||
// Allow user to pass either root host or full /cart path
|
||||
return u.endsWith("/cart") ? u : u.replace(/\/+$/, "") + "/cart";
|
||||
}
|
||||
function extractCookie(res, name) {
|
||||
const cookies = res.cookies[name];
|
||||
if (!cookies || cookies.length === 0) return null;
|
||||
return cookies[0].value;
|
||||
const cookies = res.cookies[name];
|
||||
if (!cookies || cookies.length === 0) return null;
|
||||
return cookies[0].value;
|
||||
}
|
||||
function withCookies(headers, cookieJar) {
|
||||
if (!cookieJar || Object.keys(cookieJar).length === 0) return headers;
|
||||
const cookieStr = Object.entries(cookieJar)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("; ");
|
||||
return { ...headers, Cookie: cookieStr };
|
||||
if (!cookieJar || Object.keys(cookieJar).length === 0) return headers;
|
||||
const cookieStr = Object.entries(cookieJar)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("; ");
|
||||
return { ...headers, Cookie: cookieStr };
|
||||
}
|
||||
|
||||
// Maintain cart + owner cookies per VU
|
||||
let cartState = {
|
||||
cartid: null,
|
||||
cartowner: null,
|
||||
cartid: null,
|
||||
cartowner: null,
|
||||
};
|
||||
|
||||
// Refresh cookies from response
|
||||
function updateCookies(res) {
|
||||
const cid = extractCookie(res, "cartid");
|
||||
if (cid) cartState.cartid = cid;
|
||||
const owner = extractCookie(res, "cartowner");
|
||||
if (owner) cartState.cartowner = owner;
|
||||
const cid = extractCookie(res, "cartid");
|
||||
if (cid) cartState.cartid = cid;
|
||||
const owner = extractCookie(res, "cartowner");
|
||||
if (owner) cartState.cartowner = owner;
|
||||
}
|
||||
|
||||
// Build headers
|
||||
function headers() {
|
||||
const h = { "Content-Type": "application/json" };
|
||||
const jar = {};
|
||||
if (cartState.cartid) jar["cartid"] = cartState.cartid;
|
||||
if (cartState.cartowner) jar["cartowner"] = cartState.cartowner;
|
||||
return withCookies(h, jar);
|
||||
const h = { "Content-Type": "application/json" };
|
||||
const jar = {};
|
||||
if (cartState.cartid) jar["cartid"] = cartState.cartid;
|
||||
if (cartState.cartowner) jar["cartowner"] = cartState.cartowner;
|
||||
return withCookies(h, jar);
|
||||
}
|
||||
|
||||
// Ensure cart exists (GET /)
|
||||
function ensureCart() {
|
||||
if (cartState.cartid) return;
|
||||
const res = http.get(baseUrl() + "/", { headers: headers() });
|
||||
updateCookies(res);
|
||||
check(res, {
|
||||
"ensure cart status 200": (r) => r.status === 200,
|
||||
"ensure cart has id": () => !!cartState.cartid,
|
||||
});
|
||||
if (cartState.cartid) return;
|
||||
const res = http.get(baseUrl(), { headers: headers() });
|
||||
updateCookies(res);
|
||||
check(res, {
|
||||
"ensure cart status 200": (r) => r.status === 200,
|
||||
"ensure cart has id": () => !!cartState.cartid,
|
||||
});
|
||||
}
|
||||
|
||||
// Add random item
|
||||
function addRandomItem() {
|
||||
const payload = JSON.stringify({
|
||||
sku: randomSku(),
|
||||
quantity: randomQty(),
|
||||
country: "no",
|
||||
});
|
||||
const start = Date.now();
|
||||
const res = http.post(baseUrl(), payload, { headers: headers() });
|
||||
const dur = Date.now() - start;
|
||||
addItemTrend.add(dur, { op: "add" });
|
||||
if (res.status === 200) {
|
||||
addedItemsCounter.add(1);
|
||||
}
|
||||
updateCookies(res);
|
||||
check(res, {
|
||||
"add item status ok": (r) => r.status === 200,
|
||||
});
|
||||
const payload = JSON.stringify({
|
||||
sku: randomSku(),
|
||||
quantity: randomQty(),
|
||||
country: "no",
|
||||
});
|
||||
const start = Date.now();
|
||||
const res = http.post(baseUrl(), payload, { headers: headers() });
|
||||
const dur = Date.now() - start;
|
||||
addItemTrend.add(dur, { op: "add" });
|
||||
if (res.status === 200) {
|
||||
addedItemsCounter.add(1);
|
||||
}
|
||||
updateCookies(res);
|
||||
check(res, {
|
||||
"add item status ok": (r) => r.status === 200,
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch cart state
|
||||
function fetchCart() {
|
||||
const start = Date.now();
|
||||
const res = http.get(baseUrl() + "/", { headers: headers() });
|
||||
const dur = Date.now() - start;
|
||||
fetchTrend.add(dur, { op: "get" });
|
||||
updateCookies(res);
|
||||
check(res, { "fetch status ok": (r) => r.status === 200 });
|
||||
const start = Date.now();
|
||||
const res = http.get(baseUrl(), { headers: headers() });
|
||||
const dur = Date.now() - start;
|
||||
fetchTrend.add(dur, { op: "get" });
|
||||
updateCookies(res);
|
||||
check(res, { "fetch status ok": (r) => r.status === 200 });
|
||||
}
|
||||
|
||||
// Occasional checkout trigger
|
||||
function maybeCheckout() {
|
||||
if (!cartState.cartid) return;
|
||||
// // Small probability
|
||||
// if (Math.random() < 0.02) {
|
||||
// const start = Date.now();
|
||||
// const res = http.get(baseUrl() + "/checkout", { headers: headers() });
|
||||
// const dur = Date.now() - start;
|
||||
// checkoutTrend.add(dur, { op: "checkout" });
|
||||
// updateCookies(res);
|
||||
// if (res.status === 200) checkoutCounter.add(1);
|
||||
// check(res, { "checkout status ok": (r) => r.status === 200 });
|
||||
// }
|
||||
if (!cartState.cartid) return;
|
||||
// // Small probability
|
||||
// if (Math.random() < 0.02) {
|
||||
// const start = Date.now();
|
||||
// const res = http.get(baseUrl() + "/checkout", { headers: headers() });
|
||||
// const dur = Date.now() - start;
|
||||
// checkoutTrend.add(dur, { op: "checkout" });
|
||||
// updateCookies(res);
|
||||
// if (res.status === 200) checkoutCounter.add(1);
|
||||
// check(res, { "checkout status ok": (r) => r.status === 200 });
|
||||
// }
|
||||
}
|
||||
|
||||
// ---------------- k6 lifecycle ----------------
|
||||
export function setup() {
|
||||
// Provide SKU list length for summary
|
||||
return { skuCount: SKUS.length };
|
||||
// Provide SKU list length for summary
|
||||
return { skuCount: SKUS.length };
|
||||
}
|
||||
|
||||
export default function (data) {
|
||||
group("cart flow", () => {
|
||||
// Create or reuse cart
|
||||
ensureCart();
|
||||
group("cart flow", () => {
|
||||
// Create or reuse cart
|
||||
ensureCart();
|
||||
|
||||
// Random number of item mutations per iteration (1..5)
|
||||
const ops = 1 + Math.floor(Math.random() * 5);
|
||||
for (let i = 0; i < ops; i++) {
|
||||
addRandomItem();
|
||||
}
|
||||
// Random number of item mutations per iteration (1..5)
|
||||
const ops = 1 + Math.floor(Math.random() * 5);
|
||||
for (let i = 0; i < ops; i++) {
|
||||
addRandomItem();
|
||||
}
|
||||
|
||||
// Fetch state
|
||||
fetchCart();
|
||||
// Fetch state
|
||||
fetchCart();
|
||||
|
||||
// Optional checkout attempt
|
||||
maybeCheckout();
|
||||
});
|
||||
// Optional checkout attempt
|
||||
maybeCheckout();
|
||||
});
|
||||
|
||||
// Small think time
|
||||
sleep(Math.random() * 0.5);
|
||||
// Small think time
|
||||
sleep(Math.random() * 0.5);
|
||||
}
|
||||
|
||||
export function teardown(data) {
|
||||
// Optionally we could GET confirmation or clear cart cookie
|
||||
// Not implemented for load purpose.
|
||||
console.log(`Test complete. SKU count: ${data.skuCount}`);
|
||||
// Optionally we could GET confirmation or clear cart cookie
|
||||
// Not implemented for load purpose.
|
||||
console.log(`Test complete. SKU count: ${data.skuCount}`);
|
||||
}
|
||||
|
||||
// ---------------- Summary ----------------
|
||||
export function handleSummary(data) {
|
||||
return {
|
||||
stdout: JSON.stringify(
|
||||
{
|
||||
metrics: {
|
||||
mutations_avg: data.metrics.cart_add_item_duration?.avg,
|
||||
mutations_p95: data.metrics.cart_add_item_duration?.p(95),
|
||||
fetch_p95: data.metrics.cart_fetch_duration?.p(95),
|
||||
checkout_count: data.metrics.cart_checkout_calls?.count,
|
||||
},
|
||||
checks: data.root_checks,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
};
|
||||
return {
|
||||
stdout: JSON.stringify(
|
||||
{
|
||||
metrics: {
|
||||
mutations_avg: data.metrics.cart_add_item_duration?.avg,
|
||||
mutations_p95: data.metrics.cart_add_item_duration?.p(95),
|
||||
fetch_p95: data.metrics.cart_fetch_duration?.p(95),
|
||||
checkout_count: data.metrics.cart_checkout_calls?.count,
|
||||
},
|
||||
checks: data.root_checks,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type GrainId uint64
|
||||
|
||||
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
// Reverse lookup (0xFF marks invalid)
|
||||
var base62Rev [256]byte
|
||||
|
||||
func init() {
|
||||
for i := range base62Rev {
|
||||
base62Rev[i] = 0xFF
|
||||
}
|
||||
for i := 0; i < len(base62Alphabet); i++ {
|
||||
base62Rev[base62Alphabet[i]] = byte(i)
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the canonical base62 encoding of the 64-bit id.
|
||||
func (id GrainId) String() string {
|
||||
return encodeBase62(uint64(id))
|
||||
}
|
||||
|
||||
// MarshalJSON encodes the cart id as a JSON string.
|
||||
func (id GrainId) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(id.String())
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes a cart id from a JSON string containing base62 text.
|
||||
func (id *GrainId) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
parsed, ok := ParseGrainId(s)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid cart id: %q", s)
|
||||
}
|
||||
*id = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewGrainId generates a new cryptographically random non-zero 64-bit id.
|
||||
func NewGrainId() (GrainId, error) {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return 0, fmt.Errorf("NewGrainId: %w", err)
|
||||
}
|
||||
u := (uint64(b[0]) << 56) |
|
||||
(uint64(b[1]) << 48) |
|
||||
(uint64(b[2]) << 40) |
|
||||
(uint64(b[3]) << 32) |
|
||||
(uint64(b[4]) << 24) |
|
||||
(uint64(b[5]) << 16) |
|
||||
(uint64(b[6]) << 8) |
|
||||
uint64(b[7])
|
||||
if u == 0 {
|
||||
// Extremely unlikely; regenerate once to avoid "0" identifier if desired.
|
||||
return NewGrainId()
|
||||
}
|
||||
return GrainId(u), nil
|
||||
}
|
||||
|
||||
// MustNewGrainId panics if generation fails.
|
||||
func MustNewGrainId() GrainId {
|
||||
id, err := NewGrainId()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// ParseGrainId parses a base62 string into a GrainId.
|
||||
// Returns (0,false) for invalid input.
|
||||
func ParseGrainId(s string) (GrainId, bool) {
|
||||
// Accept length 1..11 (11 sufficient for 64 bits). Reject >11 immediately.
|
||||
// Provide a slightly looser upper bound (<=16) only if you anticipate future
|
||||
// extensions; here we stay strict.
|
||||
if len(s) == 0 || len(s) > 11 {
|
||||
return 0, false
|
||||
}
|
||||
u, ok := decodeBase62(s)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return GrainId(u), true
|
||||
}
|
||||
|
||||
// MustParseGrainId panics on invalid base62 input.
|
||||
func MustParseGrainId(s string) GrainId {
|
||||
id, ok := ParseGrainId(s)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("invalid cart id: %q", s))
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// encodeBase62 converts a uint64 to base62 (shortest form).
|
||||
func encodeBase62(u uint64) string {
|
||||
if u == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [11]byte
|
||||
i := len(buf)
|
||||
for u > 0 {
|
||||
i--
|
||||
buf[i] = base62Alphabet[u%62]
|
||||
u /= 62
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
||||
// decodeBase62 converts base62 text to uint64.
|
||||
func decodeBase62(s string) (uint64, bool) {
|
||||
var v uint64
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
d := base62Rev[c]
|
||||
if d == 0xFF {
|
||||
return 0, false
|
||||
}
|
||||
v = v*62 + uint64(d)
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -9,7 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gogo/protobuf/proto"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type QueueEvent struct {
|
||||
@@ -25,8 +26,9 @@ type DiskStorage[V any] struct {
|
||||
}
|
||||
|
||||
type LogStorage[V any] interface {
|
||||
LoadEvents(id uint64, grain Grain[V]) error
|
||||
AppendEvent(id uint64, msg ...proto.Message) error
|
||||
LoadEvents(ctx context.Context, id uint64, grain Grain[V]) error
|
||||
LoadEventsFunc(ctx context.Context, id uint64, grain Grain[V], condition func(msg proto.Message, index int, timeStamp time.Time) bool) error
|
||||
AppendMutations(id uint64, msg ...proto.Message) error
|
||||
}
|
||||
|
||||
func NewDiskStorage[V any](path string, registry MutationRegistry) *DiskStorage[V] {
|
||||
@@ -86,7 +88,7 @@ func (s *DiskStorage[V]) logPath(id uint64) string {
|
||||
return filepath.Join(s.path, fmt.Sprintf("%d.events.log", id))
|
||||
}
|
||||
|
||||
func (s *DiskStorage[V]) LoadEvents(id uint64, grain Grain[V]) error {
|
||||
func (s *DiskStorage[V]) LoadEventsFunc(ctx context.Context, id uint64, grain Grain[V], condition func(msg proto.Message, index int, timeStamp time.Time) bool) error {
|
||||
path := s.logPath(id)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
// No log -> nothing to replay
|
||||
@@ -98,17 +100,40 @@ func (s *DiskStorage[V]) LoadEvents(id uint64, grain Grain[V]) error {
|
||||
return fmt.Errorf("open replay file: %w", err)
|
||||
}
|
||||
defer fh.Close()
|
||||
return s.Load(fh, func(msg proto.Message) {
|
||||
s.registry.Apply(grain, msg)
|
||||
index := 0
|
||||
return s.Load(fh, func(msg proto.Message, when time.Time) {
|
||||
if condition(msg, index, when) {
|
||||
s.registry.Apply(ctx, grain, msg)
|
||||
}
|
||||
index++
|
||||
})
|
||||
}
|
||||
|
||||
func (s *DiskStorage[V]) LoadEvents(ctx context.Context, id uint64, grain Grain[V]) error {
|
||||
path := s.logPath(id)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
// No log -> nothing to replay
|
||||
return nil
|
||||
}
|
||||
|
||||
fh, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open replay file: %w", err)
|
||||
}
|
||||
defer fh.Close()
|
||||
return s.Load(fh, func(msg proto.Message, _ time.Time) {
|
||||
s.registry.Apply(ctx, grain, msg)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *DiskStorage[V]) Close() {
|
||||
s.save()
|
||||
if s.queue != nil {
|
||||
s.save()
|
||||
}
|
||||
close(s.done)
|
||||
}
|
||||
|
||||
func (s *DiskStorage[V]) AppendEvent(id uint64, msg ...proto.Message) error {
|
||||
func (s *DiskStorage[V]) AppendMutations(id uint64, msg ...proto.Message) error {
|
||||
if s.queue != nil {
|
||||
queue := make([]QueueEvent, 0)
|
||||
data, found := s.queue.Load(id)
|
||||
@@ -122,6 +147,13 @@ func (s *DiskStorage[V]) AppendEvent(id uint64, msg ...proto.Message) error {
|
||||
return nil
|
||||
} else {
|
||||
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)
|
||||
if err != nil {
|
||||
log.Printf("failed to open event log file: %v", err)
|
||||
|
||||
+13
-8
@@ -1,9 +1,11 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gogo/protobuf/proto"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type MutationResult[V any] struct {
|
||||
@@ -12,27 +14,30 @@ type MutationResult[V any] struct {
|
||||
}
|
||||
|
||||
type GrainPool[V any] interface {
|
||||
Apply(id uint64, mutation ...proto.Message) (*MutationResult[V], error)
|
||||
Get(id uint64) (V, error)
|
||||
OwnerHost(id uint64) (Host, bool)
|
||||
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[V], error)
|
||||
Get(ctx context.Context, id uint64) (*V, error)
|
||||
OwnerHost(id uint64) (Host[V], bool)
|
||||
Hostname() string
|
||||
TakeOwnership(id uint64)
|
||||
HandleOwnershipChange(host string, ids []uint64) error
|
||||
HandleRemoteExpiry(host string, ids []uint64) error
|
||||
Negotiate(otherHosts []string)
|
||||
GetLocalIds() []uint64
|
||||
RemoveHost(host string)
|
||||
IsHealthy() bool
|
||||
IsKnown(string) bool
|
||||
Close()
|
||||
IsKnown(string) bool
|
||||
RemoveHost(host string)
|
||||
AddRemoteHost(host string)
|
||||
}
|
||||
|
||||
// Host abstracts a remote node capable of proxying cart requests.
|
||||
type Host interface {
|
||||
type Host[V any] interface {
|
||||
AnnounceExpiry(ids []uint64)
|
||||
Negotiate(otherHosts []string) ([]string, error)
|
||||
Name() string
|
||||
Proxy(id uint64, w http.ResponseWriter, r *http.Request) (bool, error)
|
||||
Proxy(id uint64, w http.ResponseWriter, r *http.Request, customBody io.Reader) (bool, error)
|
||||
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[V], error)
|
||||
Get(ctx context.Context, id uint64) (*V, error)
|
||||
GetActorIds() []uint64
|
||||
Close() error
|
||||
Ping() bool
|
||||
|
||||
+191
-9
@@ -2,42 +2,150 @@ package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/control"
|
||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/reflection"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/anypb"
|
||||
)
|
||||
|
||||
// ControlServer implements the ControlPlane gRPC services.
|
||||
// It delegates to a grain pool and cluster operations to a synced pool.
|
||||
type ControlServer[V any] struct {
|
||||
messages.UnimplementedControlPlaneServer
|
||||
|
||||
pool GrainPool[V]
|
||||
}
|
||||
|
||||
const name = "grpc_server"
|
||||
|
||||
var (
|
||||
tracer = otel.Tracer(name)
|
||||
meter = otel.Meter(name)
|
||||
logger = otelslog.NewLogger(name)
|
||||
pingCalls metric.Int64Counter
|
||||
negotiateCalls metric.Int64Counter
|
||||
getLocalActorIdsCalls metric.Int64Counter
|
||||
announceOwnershipCalls metric.Int64Counter
|
||||
announceExpiryCalls metric.Int64Counter
|
||||
closingCalls metric.Int64Counter
|
||||
)
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
pingCalls, err = meter.Int64Counter("grpc.ping_calls",
|
||||
metric.WithDescription("Number of ping calls"),
|
||||
metric.WithUnit("{calls}"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
negotiateCalls, err = meter.Int64Counter("grpc.negotiate_calls",
|
||||
metric.WithDescription("Number of negotiate calls"),
|
||||
metric.WithUnit("{calls}"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
getLocalActorIdsCalls, err = meter.Int64Counter("grpc.get_local_actor_ids_calls",
|
||||
metric.WithDescription("Number of get local actor ids calls"),
|
||||
metric.WithUnit("{calls}"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
announceOwnershipCalls, err = meter.Int64Counter("grpc.announce_ownership_calls",
|
||||
metric.WithDescription("Number of announce ownership calls"),
|
||||
metric.WithUnit("{calls}"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
announceExpiryCalls, err = meter.Int64Counter("grpc.announce_expiry_calls",
|
||||
metric.WithDescription("Number of announce expiry calls"),
|
||||
metric.WithUnit("{calls}"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
closingCalls, err = meter.Int64Counter("grpc.closing_calls",
|
||||
metric.WithDescription("Number of closing calls"),
|
||||
metric.WithUnit("{calls}"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ControlServer[V]) AnnounceOwnership(ctx context.Context, req *messages.OwnershipAnnounce) (*messages.OwnerChangeAck, error) {
|
||||
ctx, span := tracer.Start(ctx, "grpc_announce_ownership")
|
||||
defer span.End()
|
||||
span.SetAttributes(
|
||||
attribute.String("component", "controlplane"),
|
||||
attribute.String("host", req.Host),
|
||||
attribute.Int("id_count", len(req.Ids)),
|
||||
)
|
||||
logger.InfoContext(ctx, "announce ownership", "host", req.Host, "id_count", len(req.Ids))
|
||||
announceOwnershipCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
|
||||
|
||||
err := s.pool.HandleOwnershipChange(req.Host, req.Ids)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return &messages.OwnerChangeAck{
|
||||
Accepted: false,
|
||||
Message: "owner change failed",
|
||||
}, err
|
||||
}
|
||||
|
||||
log.Printf("Ack count: %d", len(req.Ids))
|
||||
// log.Printf("Ack count: %d", len(req.Ids))
|
||||
return &messages.OwnerChangeAck{
|
||||
Accepted: true,
|
||||
Message: "ownership announced",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toAny[V any](grain V) (*anypb.Any, error) {
|
||||
data, err := json.Marshal(grain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &anypb.Any{
|
||||
Value: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ControlServer[V]) Get(ctx context.Context, req *messages.GetRequest) (*messages.GetReply, error) {
|
||||
grain, err := s.pool.Get(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
grainAny, err := toAny(grain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &messages.GetReply{
|
||||
Grain: grainAny,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ControlServer[V]) AnnounceExpiry(ctx context.Context, req *messages.ExpiryAnnounce) (*messages.OwnerChangeAck, error) {
|
||||
ctx, span := tracer.Start(ctx, "grpc_announce_expiry")
|
||||
defer span.End()
|
||||
span.SetAttributes(
|
||||
attribute.String("component", "controlplane"),
|
||||
attribute.String("host", req.Host),
|
||||
attribute.Int("id_count", len(req.Ids)),
|
||||
)
|
||||
logger.InfoContext(ctx, "announce expiry", "host", req.Host, "id_count", len(req.Ids))
|
||||
announceExpiryCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
|
||||
|
||||
err := s.pool.HandleRemoteExpiry(req.Host, req.Ids)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
}
|
||||
return &messages.OwnerChangeAck{
|
||||
Accepted: err == nil,
|
||||
Message: "expiry acknowledged",
|
||||
@@ -45,30 +153,104 @@ func (s *ControlServer[V]) AnnounceExpiry(ctx context.Context, req *messages.Exp
|
||||
}
|
||||
|
||||
// ControlPlane: Ping
|
||||
func (s *ControlServer[V]) Ping(ctx context.Context, _ *messages.Empty) (*messages.PingReply, error) {
|
||||
func (s *ControlServer[V]) Ping(ctx context.Context, req *messages.Empty) (*messages.PingReply, error) {
|
||||
|
||||
host := s.pool.Hostname()
|
||||
|
||||
pingCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", host)))
|
||||
|
||||
// log.Printf("got ping")
|
||||
return &messages.PingReply{
|
||||
Host: s.pool.Hostname(),
|
||||
Host: host,
|
||||
UnixTime: time.Now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ControlServer[V]) Apply(ctx context.Context, in *messages.ApplyRequest) (*messages.ApplyResult, error) {
|
||||
msgs := make([]proto.Message, len(in.Messages))
|
||||
for i, anyMsg := range in.Messages {
|
||||
msg, err := anyMsg.UnmarshalNew()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal message: %w", err)
|
||||
}
|
||||
msgs[i] = msg
|
||||
}
|
||||
r, err := s.pool.Apply(ctx, in.Id, msgs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
grainAny, err := toAny(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mutList := make([]*messages.MutationResult, len(in.Messages))
|
||||
for i, msg := range r.Mutations {
|
||||
mut, err := anypb.New(msg.Mutation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var errString *string
|
||||
if msg.Error != nil {
|
||||
s := msg.Error.Error()
|
||||
errString = &s
|
||||
}
|
||||
mutList[i] = &messages.MutationResult{
|
||||
Type: msg.Type,
|
||||
Message: mut,
|
||||
Error: errString,
|
||||
}
|
||||
}
|
||||
|
||||
return &messages.ApplyResult{
|
||||
State: grainAny,
|
||||
Mutations: mutList,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ControlPlane: Negotiate (merge host views)
|
||||
func (s *ControlServer[V]) Negotiate(ctx context.Context, req *messages.NegotiateRequest) (*messages.NegotiateReply, error) {
|
||||
ctx, span := tracer.Start(ctx, "grpc_negotiate")
|
||||
defer span.End()
|
||||
span.SetAttributes(
|
||||
attribute.String("component", "controlplane"),
|
||||
attribute.Int("known_hosts_count", len(req.KnownHosts)),
|
||||
)
|
||||
logger.InfoContext(ctx, "negotiate", "known_hosts_count", len(req.KnownHosts))
|
||||
negotiateCalls.Add(ctx, 1)
|
||||
|
||||
s.pool.Negotiate(req.KnownHosts)
|
||||
return &messages.NegotiateReply{Hosts: req.GetKnownHosts()}, nil
|
||||
}
|
||||
|
||||
// ControlPlane: GetCartIds (locally owned carts only)
|
||||
func (s *ControlServer[V]) GetLocalActorIds(ctx context.Context, _ *messages.Empty) (*messages.ActorIdsReply, error) {
|
||||
return &messages.ActorIdsReply{Ids: s.pool.GetLocalIds()}, nil
|
||||
func (s *ControlServer[V]) GetLocalActorIds(ctx context.Context, req *messages.Empty) (*messages.ActorIdsReply, error) {
|
||||
ctx, span := tracer.Start(ctx, "grpc_get_local_actor_ids")
|
||||
defer span.End()
|
||||
ids := s.pool.GetLocalIds()
|
||||
span.SetAttributes(
|
||||
attribute.String("component", "controlplane"),
|
||||
attribute.Int("id_count", len(ids)),
|
||||
)
|
||||
logger.InfoContext(ctx, "get local actor ids", "id_count", len(ids))
|
||||
getLocalActorIdsCalls.Add(ctx, 1)
|
||||
|
||||
return &messages.ActorIdsReply{Ids: ids}, nil
|
||||
}
|
||||
|
||||
// ControlPlane: Closing (peer shutdown notification)
|
||||
func (s *ControlServer[V]) Closing(ctx context.Context, req *messages.ClosingNotice) (*messages.OwnerChangeAck, error) {
|
||||
if req.GetHost() != "" {
|
||||
s.pool.RemoveHost(req.GetHost())
|
||||
ctx, span := tracer.Start(ctx, "grpc_closing")
|
||||
defer span.End()
|
||||
host := req.GetHost()
|
||||
span.SetAttributes(
|
||||
attribute.String("component", "controlplane"),
|
||||
attribute.String("host", host),
|
||||
)
|
||||
logger.InfoContext(ctx, "closing notice", "host", host)
|
||||
closingCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", host)))
|
||||
|
||||
if host != "" {
|
||||
s.pool.RemoveHost(host)
|
||||
}
|
||||
return &messages.OwnerChangeAck{
|
||||
Accepted: true,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||
control_plane_messages "git.k6n.net/mats/go-cart-actor/proto/control"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/anypb"
|
||||
)
|
||||
|
||||
// MockGrainPool for testing
|
||||
type mockGrainPool struct {
|
||||
applied []proto.Message
|
||||
}
|
||||
|
||||
func (m *mockGrainPool) Apply(ctx context.Context, id uint64, mutations ...proto.Message) (*MutationResult[mockGrain], error) {
|
||||
m.applied = mutations
|
||||
// Simulate successful application
|
||||
return &MutationResult[mockGrain]{
|
||||
Result: mockGrain{},
|
||||
Mutations: []ApplyResult{
|
||||
{Type: "AddItem", Mutation: &cart_messages.AddItem{ItemId: 1, Quantity: 2}, Error: nil},
|
||||
{Type: "RemoveItem", Mutation: &cart_messages.RemoveItem{Id: 1}, Error: nil},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockGrainPool) Get(ctx context.Context, id uint64) (*mockGrain, error) {
|
||||
return &mockGrain{}, nil
|
||||
}
|
||||
|
||||
func (m *mockGrainPool) OwnerHost(id uint64) (Host[mockGrain], bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m *mockGrainPool) TakeOwnership(id uint64) {}
|
||||
|
||||
func (m *mockGrainPool) Hostname() string { return "test-host" }
|
||||
|
||||
func (m *mockGrainPool) HandleOwnershipChange(host string, ids []uint64) error { return nil }
|
||||
func (m *mockGrainPool) HandleRemoteExpiry(host string, ids []uint64) error { return nil }
|
||||
func (m *mockGrainPool) Negotiate(hosts []string) {}
|
||||
func (m *mockGrainPool) GetLocalIds() []uint64 { return []uint64{} }
|
||||
func (m *mockGrainPool) RemoveHost(host string) {}
|
||||
func (m *mockGrainPool) AddRemoteHost(host string) {}
|
||||
func (m *mockGrainPool) IsHealthy() bool { return true }
|
||||
func (m *mockGrainPool) IsKnown(host string) bool { return false }
|
||||
func (m *mockGrainPool) Close() {}
|
||||
|
||||
type mockGrain struct{}
|
||||
|
||||
func TestApplyRequestWithMutations(t *testing.T) {
|
||||
// Setup mock pool
|
||||
pool := &mockGrainPool{}
|
||||
|
||||
// Create gRPC server
|
||||
server, err := NewControlServer[mockGrain](DefaultServerConfig(), pool)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create server: %v", err)
|
||||
}
|
||||
defer server.GracefulStop()
|
||||
|
||||
// Create client connection
|
||||
conn, err := grpc.Dial("localhost:1337", grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client := control_plane_messages.NewControlPlaneClient(conn)
|
||||
|
||||
// Prepare ApplyRequest with multiple Any messages
|
||||
addItemAny, _ := anypb.New(&cart_messages.AddItem{ItemId: 1, Quantity: 2})
|
||||
removeItemAny, _ := anypb.New(&cart_messages.RemoveItem{Id: 1})
|
||||
req := &control_plane_messages.ApplyRequest{
|
||||
Id: 123,
|
||||
Messages: []*anypb.Any{addItemAny, removeItemAny},
|
||||
}
|
||||
|
||||
// Call Apply
|
||||
resp, err := client.Apply(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Apply failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify response
|
||||
if resp.State == nil {
|
||||
t.Errorf("expected State to be non-nil")
|
||||
}
|
||||
if len(resp.Mutations) != 2 {
|
||||
t.Errorf("expected 2 mutation results, got %d", len(resp.Mutations))
|
||||
}
|
||||
for i, mut := range resp.Mutations {
|
||||
if mut.Error != nil {
|
||||
t.Errorf("expected no error in mutation %d, got %s", i, *mut.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify mutations were extracted and applied
|
||||
if len(pool.applied) != 2 {
|
||||
t.Errorf("expected 2 mutations applied, got %d", len(pool.applied))
|
||||
}
|
||||
if addItem, ok := pool.applied[0].(*cart_messages.AddItem); !ok || addItem.ItemId != 1 {
|
||||
t.Errorf("expected AddItem with ItemId=1, got %v", pool.applied[0])
|
||||
}
|
||||
if removeItem, ok := pool.applied[1].(*cart_messages.RemoveItem); !ok || removeItem.Id != 1 {
|
||||
t.Errorf("expected RemoveItem with Id=1, got %v", pool.applied[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRequest(t *testing.T) {
|
||||
// Setup mock pool
|
||||
pool := &mockGrainPool{}
|
||||
|
||||
// Create gRPC server
|
||||
server, err := NewControlServer[mockGrain](DefaultServerConfig(), pool)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create server: %v", err)
|
||||
}
|
||||
defer server.GracefulStop()
|
||||
|
||||
// Create client connection
|
||||
conn, err := grpc.Dial("localhost:1337", grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client := control_plane_messages.NewControlPlaneClient(conn)
|
||||
|
||||
// Prepare GetRequest
|
||||
req := &control_plane_messages.GetRequest{
|
||||
Id: 123,
|
||||
}
|
||||
|
||||
// Call Get
|
||||
resp, err := client.Get(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Get failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify response
|
||||
if resp.Grain == nil {
|
||||
t.Errorf("expected Grain to be non-nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"git.k6n.net/mats/slask-finder/pkg/messaging"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
type LogListener interface {
|
||||
AppendMutations(id uint64, msg ...ApplyResult)
|
||||
}
|
||||
|
||||
type AmqpListener struct {
|
||||
conn *amqp.Connection
|
||||
transformer func(id uint64, msg []ApplyResult) (any, error)
|
||||
}
|
||||
|
||||
func NewAmqpListener(conn *amqp.Connection, transformer func(id uint64, msg []ApplyResult) (any, error)) *AmqpListener {
|
||||
return &AmqpListener{
|
||||
conn: conn,
|
||||
transformer: transformer,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *AmqpListener) DefineTopics() {
|
||||
ch, err := l.conn.Channel()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open a channel: %v", err)
|
||||
}
|
||||
defer ch.Close()
|
||||
if err := messaging.DefineTopic(ch, "cart", "mutation"); err != nil {
|
||||
log.Fatalf("Failed to declare topic mutation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *AmqpListener) AppendMutations(id uint64, msg ...ApplyResult) {
|
||||
data, err := l.transformer(id, msg)
|
||||
if err != nil {
|
||||
log.Printf("Failed to transform mutation event: %v", err)
|
||||
return
|
||||
}
|
||||
err = messaging.SendChange(l.conn, "cart", "mutation", data)
|
||||
if err != nil {
|
||||
log.Printf("Failed to send mutation event: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"reflect"
|
||||
"sync"
|
||||
|
||||
"github.com/gogo/protobuf/proto"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type ApplyResult struct {
|
||||
@@ -15,11 +17,30 @@ type ApplyResult struct {
|
||||
Error error `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type MutationProcessor interface {
|
||||
Process(ctx context.Context, grain any) error
|
||||
}
|
||||
|
||||
type BasicMutationProcessor[V any] struct {
|
||||
processor func(ctx context.Context, grain V) error
|
||||
}
|
||||
|
||||
func NewMutationProcessor[V any](process func(ctx context.Context, grain V) error) MutationProcessor {
|
||||
return &BasicMutationProcessor[V]{
|
||||
processor: process,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *BasicMutationProcessor[V]) Process(ctx context.Context, grain any) error {
|
||||
return p.processor(ctx, grain.(V))
|
||||
}
|
||||
|
||||
type MutationRegistry interface {
|
||||
Apply(grain any, msg ...proto.Message) ([]ApplyResult, error)
|
||||
Apply(ctx context.Context, grain any, msg ...proto.Message) ([]ApplyResult, error)
|
||||
RegisterMutations(handlers ...MutationHandler)
|
||||
Create(typeName string) (proto.Message, bool)
|
||||
GetTypeName(msg proto.Message) (string, bool)
|
||||
RegisterProcessor(processor ...MutationProcessor)
|
||||
//GetStorageEvent(msg proto.Message) StorageEvent
|
||||
//FromStorageEvent(event StorageEvent) (proto.Message, error)
|
||||
}
|
||||
@@ -27,6 +48,7 @@ type MutationRegistry interface {
|
||||
type ProtoMutationRegistry struct {
|
||||
mutationRegistryMu sync.RWMutex
|
||||
mutationRegistry map[reflect.Type]MutationHandler
|
||||
processors []MutationProcessor
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -73,17 +95,26 @@ type MutationHandler interface {
|
||||
type RegisteredMutation[V any, T proto.Message] struct {
|
||||
name string
|
||||
handler func(*V, T) error
|
||||
create func() T
|
||||
create func() proto.Message
|
||||
msgType reflect.Type
|
||||
}
|
||||
|
||||
func NewMutation[V any, T proto.Message](handler func(*V, T) error, create func() T) *RegisteredMutation[V, T] {
|
||||
func NewMutation[V any, T proto.Message](handler func(*V, T) error) *RegisteredMutation[V, T] {
|
||||
// Derive the name and message type from a concrete instance produced by create().
|
||||
// This avoids relying on reflect.TypeFor (which can yield unexpected results in some toolchains)
|
||||
// and ensures we always peel off the pointer layer for proto messages.
|
||||
create := func() proto.Message {
|
||||
var t T
|
||||
rt := reflect.TypeOf(t)
|
||||
if rt != nil && rt.Kind() == reflect.Pointer {
|
||||
return reflect.New(rt.Elem()).Interface().(proto.Message)
|
||||
}
|
||||
log.Fatalf("expected to create proto message got %+v", rt)
|
||||
return nil
|
||||
}
|
||||
instance := create()
|
||||
rt := reflect.TypeOf(instance)
|
||||
if rt.Kind() == reflect.Ptr {
|
||||
if rt.Kind() == reflect.Pointer {
|
||||
rt = rt.Elem()
|
||||
}
|
||||
return &RegisteredMutation[V, T]{
|
||||
@@ -114,9 +145,14 @@ func NewMutationRegistry() MutationRegistry {
|
||||
return &ProtoMutationRegistry{
|
||||
mutationRegistry: make(map[reflect.Type]MutationHandler),
|
||||
mutationRegistryMu: sync.RWMutex{},
|
||||
processors: make([]MutationProcessor, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ProtoMutationRegistry) RegisterProcessor(processors ...MutationProcessor) {
|
||||
r.processors = append(r.processors, processors...)
|
||||
}
|
||||
|
||||
func (r *ProtoMutationRegistry) RegisterMutations(handlers ...MutationHandler) {
|
||||
r.mutationRegistryMu.Lock()
|
||||
defer r.mutationRegistryMu.Unlock()
|
||||
@@ -165,33 +201,71 @@ func (r *ProtoMutationRegistry) Create(typeName string) (proto.Message, bool) {
|
||||
// Returns updated grain if successful.
|
||||
//
|
||||
// If the mutation is not registered, returns (nil, ErrMutationNotRegistered).
|
||||
func (r *ProtoMutationRegistry) Apply(grain any, msg ...proto.Message) ([]ApplyResult, error) {
|
||||
func (r *ProtoMutationRegistry) Apply(ctx context.Context, grain any, msg ...proto.Message) ([]ApplyResult, error) {
|
||||
|
||||
parentCtx, span := tracer.Start(ctx, "apply mutations")
|
||||
defer span.End()
|
||||
span.SetAttributes(
|
||||
attribute.String("component", "registry"),
|
||||
attribute.Int("mutations", len(msg)),
|
||||
)
|
||||
|
||||
results := make([]ApplyResult, 0, len(msg))
|
||||
|
||||
if grain == nil {
|
||||
return results, fmt.Errorf("nil grain")
|
||||
}
|
||||
// Nil slice of mutations still treated as an error (call contract violation).
|
||||
if msg == nil {
|
||||
return results, fmt.Errorf("nil mutation message")
|
||||
}
|
||||
|
||||
for _, m := range msg {
|
||||
// Error if any mutation element is nil.
|
||||
if m == nil {
|
||||
return results, fmt.Errorf("nil mutation message")
|
||||
}
|
||||
// Typed nil: interface holds concrete proto message type whose pointer value is nil.
|
||||
rv := reflect.ValueOf(m)
|
||||
if rv.Kind() == reflect.Pointer && rv.IsNil() {
|
||||
continue
|
||||
}
|
||||
rt := indirectType(reflect.TypeOf(m))
|
||||
_, msgSpan := tracer.Start(parentCtx, rt.Name())
|
||||
|
||||
r.mutationRegistryMu.RLock()
|
||||
entry, ok := r.mutationRegistry[rt]
|
||||
r.mutationRegistryMu.RUnlock()
|
||||
if !ok {
|
||||
results = append(results, ApplyResult{Error: ErrMutationNotRegistered, Type: rt.Name(), Mutation: m})
|
||||
continue
|
||||
} else {
|
||||
err := entry.Handle(grain, m)
|
||||
if err != nil {
|
||||
msgSpan.RecordError(err)
|
||||
}
|
||||
results = append(results, ApplyResult{Error: err, Type: rt.Name(), Mutation: m})
|
||||
}
|
||||
err := entry.Handle(grain, m)
|
||||
results = append(results, ApplyResult{Error: err, Type: rt.Name(), Mutation: m})
|
||||
msgSpan.End()
|
||||
}
|
||||
|
||||
// if entry.updateTotals {
|
||||
// grain.UpdateTotals()
|
||||
// }
|
||||
if len(results) > 0 {
|
||||
processCtx, processSpan := tracer.Start(ctx, "after mutation processors")
|
||||
defer processSpan.End()
|
||||
for _, processor := range r.processors {
|
||||
|
||||
err := processor.Process(processCtx, grain)
|
||||
if err != nil {
|
||||
return results, err
|
||||
}
|
||||
}
|
||||
}
|
||||
// Return error for unregistered mutations
|
||||
for _, res := range results {
|
||||
if res.Error == ErrMutationNotRegistered {
|
||||
return results, res.Error
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +1,37 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"context"
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||
)
|
||||
|
||||
type cartState struct {
|
||||
calls int
|
||||
lastAdded *messages.AddItem
|
||||
lastAdded *cart_messages.AddItem
|
||||
}
|
||||
|
||||
func TestRegisteredMutationBasics(t *testing.T) {
|
||||
reg := NewMutationRegistry().(*ProtoMutationRegistry)
|
||||
|
||||
addItemMutation := NewMutation(
|
||||
func(state *cartState, msg *messages.AddItem) error {
|
||||
func(state *cartState, msg *cart_messages.AddItem) error {
|
||||
state.calls++
|
||||
// copy to avoid external mutation side-effects (not strictly necessary for the test)
|
||||
cp := *msg
|
||||
state.lastAdded = &cp
|
||||
cp := msg
|
||||
state.lastAdded = cp
|
||||
return nil
|
||||
},
|
||||
func() *messages.AddItem { return &messages.AddItem{} },
|
||||
)
|
||||
|
||||
// Sanity check on mutation metadata
|
||||
if addItemMutation.Name() != "AddItem" {
|
||||
t.Fatalf("expected mutation Name() == AddItem, got %s", addItemMutation.Name())
|
||||
}
|
||||
if got, want := addItemMutation.Type(), reflect.TypeOf(messages.AddItem{}); got != want {
|
||||
if got, want := addItemMutation.Type(), reflect.TypeOf(cart_messages.AddItem{}); got != want {
|
||||
t.Fatalf("expected Type() == %v, got %v", want, got)
|
||||
}
|
||||
|
||||
@@ -46,18 +45,18 @@ func TestRegisteredMutationBasics(t *testing.T) {
|
||||
|
||||
// RegisteredMutationTypes: membership (order not guaranteed)
|
||||
types := reg.RegisteredMutationTypes()
|
||||
if !slices.Contains(types, reflect.TypeOf(messages.AddItem{})) {
|
||||
if !slices.Contains(types, reflect.TypeOf(cart_messages.AddItem{})) {
|
||||
t.Fatalf("RegisteredMutationTypes missing AddItem type, got %v", types)
|
||||
}
|
||||
|
||||
// GetTypeName should resolve for a pointer instance
|
||||
name, ok := reg.GetTypeName(&messages.AddItem{})
|
||||
name, ok := reg.GetTypeName(&cart_messages.AddItem{})
|
||||
if !ok || name != "AddItem" {
|
||||
t.Fatalf("GetTypeName returned (%q,%v), expected (AddItem,true)", name, ok)
|
||||
}
|
||||
|
||||
// GetTypeName should fail for unregistered type
|
||||
if name, ok := reg.GetTypeName(&messages.Noop{}); ok || name != "" {
|
||||
if name, ok := reg.GetTypeName(&cart_messages.RemoveItem{}); ok || name != "" {
|
||||
t.Fatalf("expected GetTypeName to fail for unregistered message, got (%q,%v)", name, ok)
|
||||
}
|
||||
|
||||
@@ -66,7 +65,7 @@ func TestRegisteredMutationBasics(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("Create failed for registered mutation")
|
||||
}
|
||||
if _, isAddItem := msg.(*messages.AddItem); !isAddItem {
|
||||
if _, isAddItem := msg.(*cart_messages.AddItem); !isAddItem {
|
||||
t.Fatalf("Create returned wrong concrete type: %T", msg)
|
||||
}
|
||||
|
||||
@@ -77,8 +76,8 @@ func TestRegisteredMutationBasics(t *testing.T) {
|
||||
|
||||
// Apply happy path
|
||||
state := &cartState{}
|
||||
add := &messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
|
||||
if _, err := reg.Apply(state, add); err != nil {
|
||||
add := &cart_messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
|
||||
if _, err := reg.Apply(context.Background(), state, add); err != nil {
|
||||
t.Fatalf("Apply returned error: %v", err)
|
||||
}
|
||||
if state.calls != 1 {
|
||||
@@ -89,17 +88,18 @@ func TestRegisteredMutationBasics(t *testing.T) {
|
||||
}
|
||||
|
||||
// Apply nil grain
|
||||
if _, err := reg.Apply(nil, add); err == nil {
|
||||
if _, err := reg.Apply(context.Background(), nil, add); err == nil {
|
||||
t.Fatalf("expected error for nil grain")
|
||||
}
|
||||
|
||||
// Apply nil message
|
||||
if _, err := reg.Apply(state, nil); err == nil {
|
||||
if _, err := reg.Apply(context.Background(), state, nil); err == nil {
|
||||
t.Fatalf("expected error for nil mutation message")
|
||||
}
|
||||
|
||||
// Apply unregistered message
|
||||
if _, err := reg.Apply(state, &messages.Noop{}); !errors.Is(err, ErrMutationNotRegistered) {
|
||||
_, err := reg.Apply(context.Background(), state, &cart_messages.RemoveItem{})
|
||||
if err != ErrMutationNotRegistered {
|
||||
t.Fatalf("expected ErrMutationNotRegistered, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"iter"
|
||||
"slices"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type ReceiverFunc[V any] func(event V)
|
||||
|
||||
type PubSub[V any] struct {
|
||||
subscribers []*ReceiverFunc[V]
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewPubSub creates a new PubSub instance.
|
||||
func NewPubSub[V any]() *PubSub[V] {
|
||||
return &PubSub[V]{
|
||||
subscribers: make([]*ReceiverFunc[V], 0),
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe adds a grain ID to the subscribers of a topic.
|
||||
func (p *PubSub[V]) Subscribe(receiver ReceiverFunc[V]) {
|
||||
if receiver == nil {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
// log.Printf("adding subscriber")
|
||||
p.subscribers = append(p.subscribers, &receiver)
|
||||
}
|
||||
|
||||
// Unsubscribe removes a grain ID from the subscribers of a topic.
|
||||
func (p *PubSub[V]) Unsubscribe(receiver ReceiverFunc[V]) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
list := p.subscribers
|
||||
prt := &receiver
|
||||
for i, sub := range list {
|
||||
if sub == nil {
|
||||
continue
|
||||
}
|
||||
if sub == prt {
|
||||
// log.Printf("removing subscriber")
|
||||
p.subscribers = append(list[:i], list[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
p.subscribers = slices.DeleteFunc(p.subscribers, func(fn *ReceiverFunc[V]) bool {
|
||||
return fn == nil
|
||||
})
|
||||
// If list is empty, could delete, but not necessary
|
||||
}
|
||||
|
||||
// GetSubscribers returns a copy of the subscriber IDs for a topic.
|
||||
func (p *PubSub[V]) GetSubscribers() iter.Seq[ReceiverFunc[V]] {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
return func(yield func(ReceiverFunc[V]) bool) {
|
||||
for _, sub := range p.subscribers {
|
||||
if sub == nil {
|
||||
continue
|
||||
}
|
||||
if !yield(*sub) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Publish sends an event to all subscribers of the topic.
|
||||
func (p *PubSub[V]) Publish(event V) {
|
||||
for notify := range p.GetSubscribers() {
|
||||
notify(event)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"maps"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gogo/protobuf/proto"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type SimpleGrainPool[V any] struct {
|
||||
@@ -15,17 +16,23 @@ type SimpleGrainPool[V any] struct {
|
||||
localMu sync.RWMutex
|
||||
grains map[uint64]Grain[V]
|
||||
mutationRegistry MutationRegistry
|
||||
spawn func(id uint64) (Grain[V], error)
|
||||
spawnHost func(host string) (Host, error)
|
||||
spawn func(ctx context.Context, id uint64) (Grain[V], error)
|
||||
destroy func(grain Grain[V]) error
|
||||
spawnHost func(host string) (Host[V], error)
|
||||
listeners []LogListener
|
||||
storage LogStorage[V]
|
||||
ttl time.Duration
|
||||
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 --------------------------------------------------
|
||||
hostname string
|
||||
remoteMu sync.RWMutex
|
||||
remoteOwners map[uint64]Host
|
||||
remoteHosts map[string]Host
|
||||
remoteOwners map[uint64]Host[V]
|
||||
remoteHosts map[string]Host[V]
|
||||
//discardedHostHandler *DiscardedHostHandler
|
||||
|
||||
// House-keeping ---------------------------------------------------------
|
||||
@@ -34,8 +41,12 @@ type SimpleGrainPool[V any] struct {
|
||||
|
||||
type GrainPoolConfig[V any] struct {
|
||||
Hostname string
|
||||
Spawn func(id uint64) (Grain[V], error)
|
||||
SpawnHost func(host string) (Host, error)
|
||||
Spawn func(ctx context.Context, id uint64) (Grain[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
|
||||
TTL time.Duration
|
||||
PoolSize int
|
||||
MutationRegistry MutationRegistry
|
||||
@@ -49,11 +60,13 @@ func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V],
|
||||
storage: config.Storage,
|
||||
spawn: config.Spawn,
|
||||
spawnHost: config.SpawnHost,
|
||||
destroy: config.Destroy,
|
||||
ttl: config.TTL,
|
||||
poolSize: config.PoolSize,
|
||||
hostname: config.Hostname,
|
||||
remoteOwners: make(map[uint64]Host),
|
||||
remoteHosts: make(map[string]Host),
|
||||
remoteOwners: make(map[uint64]Host[V]),
|
||||
remoteHosts: make(map[string]Host[V]),
|
||||
grainLocks: newKeyedMutex(),
|
||||
}
|
||||
|
||||
p.purgeTicker = time.NewTicker(time.Minute)
|
||||
@@ -66,6 +79,19 @@ func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V],
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (p *SimpleGrainPool[V]) AddListener(listener LogListener) {
|
||||
p.listeners = append(p.listeners, listener)
|
||||
}
|
||||
|
||||
func (p *SimpleGrainPool[V]) RemoveListener(listener LogListener) {
|
||||
for i, l := range p.listeners {
|
||||
if l == listener {
|
||||
p.listeners = append(p.listeners[:i], p.listeners[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *SimpleGrainPool[V]) purge() {
|
||||
purgeLimit := time.Now().Add(-p.ttl)
|
||||
purgedIds := make([]uint64, 0, len(p.grains))
|
||||
@@ -73,12 +99,17 @@ func (p *SimpleGrainPool[V]) purge() {
|
||||
for id, grain := range p.grains {
|
||||
if grain.GetLastAccess().Before(purgeLimit) {
|
||||
purgedIds = append(purgedIds, id)
|
||||
if p.destroy != nil {
|
||||
if err := p.destroy(grain); err != nil {
|
||||
log.Printf("failed to destroy grain %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
delete(p.grains, id)
|
||||
}
|
||||
}
|
||||
p.localMu.Unlock()
|
||||
p.forAllHosts(func(remote Host) {
|
||||
p.forAllHosts(func(remote Host[V]) {
|
||||
remote.AnnounceExpiry(purgedIds)
|
||||
})
|
||||
|
||||
@@ -115,7 +146,6 @@ func (p *SimpleGrainPool[V]) HandleRemoteExpiry(host string, ids []uint64) error
|
||||
}
|
||||
|
||||
func (p *SimpleGrainPool[V]) HandleOwnershipChange(host string, ids []uint64) error {
|
||||
log.Printf("host %s now owns %d cart ids", host, len(ids))
|
||||
p.remoteMu.RLock()
|
||||
remoteHost, exists := p.remoteHosts[host]
|
||||
p.remoteMu.RUnlock()
|
||||
@@ -143,7 +173,11 @@ func (p *SimpleGrainPool[V]) TakeOwnership(id uint64) {
|
||||
p.broadcastOwnership([]uint64{id})
|
||||
}
|
||||
|
||||
func (p *SimpleGrainPool[V]) AddRemote(host string) (Host, error) {
|
||||
func (p *SimpleGrainPool[V]) AddRemoteHost(host string) {
|
||||
p.AddRemote(host)
|
||||
}
|
||||
|
||||
func (p *SimpleGrainPool[V]) AddRemote(host string) (Host[V], error) {
|
||||
if host == "" {
|
||||
return nil, fmt.Errorf("host is empty")
|
||||
}
|
||||
@@ -163,7 +197,17 @@ func (p *SimpleGrainPool[V]) AddRemote(host string) (Host, error) {
|
||||
|
||||
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()
|
||||
if existing, found := p.remoteHosts[host]; found {
|
||||
p.remoteMu.Unlock()
|
||||
go remote.Close()
|
||||
return existing, nil
|
||||
}
|
||||
p.remoteHosts[host] = remote
|
||||
p.remoteMu.Unlock()
|
||||
// connectedRemotes.Set(float64(p.RemoteCount()))
|
||||
@@ -175,7 +219,7 @@ func (p *SimpleGrainPool[V]) AddRemote(host string) (Host, error) {
|
||||
return remote, nil
|
||||
}
|
||||
|
||||
func (p *SimpleGrainPool[V]) initializeRemote(remote Host) {
|
||||
func (p *SimpleGrainPool[V]) initializeRemote(remote Host[V]) {
|
||||
|
||||
remotesIds := remote.GetActorIds()
|
||||
|
||||
@@ -197,7 +241,6 @@ func (p *SimpleGrainPool[V]) RemoveHost(host string) {
|
||||
remote, exists := p.remoteHosts[host]
|
||||
|
||||
if exists {
|
||||
go remote.Close()
|
||||
delete(p.remoteHosts, host)
|
||||
}
|
||||
count := 0
|
||||
@@ -210,6 +253,7 @@ func (p *SimpleGrainPool[V]) RemoveHost(host string) {
|
||||
log.Printf("Removing host %s, grains: %d", host, count)
|
||||
p.remoteMu.Unlock()
|
||||
|
||||
// Close once, outside the lock.
|
||||
if exists {
|
||||
remote.Close()
|
||||
}
|
||||
@@ -243,7 +287,7 @@ func (p *SimpleGrainPool[V]) IsKnown(host string) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
func (p *SimpleGrainPool[V]) pingLoop(remote Host) {
|
||||
func (p *SimpleGrainPool[V]) pingLoop(remote Host[V]) {
|
||||
remote.Ping()
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
@@ -251,7 +295,9 @@ func (p *SimpleGrainPool[V]) pingLoop(remote Host) {
|
||||
if !remote.Ping() {
|
||||
if !remote.IsHealthy() {
|
||||
log.Printf("Remote %s unhealthy, removing", remote.Name())
|
||||
p.Close()
|
||||
// Remove only this host. Previously this called p.Close(),
|
||||
// which tore down every remote connection and stopped the
|
||||
// purge ticker for the whole pool.
|
||||
p.RemoveHost(remote.Name())
|
||||
return
|
||||
}
|
||||
@@ -291,14 +337,14 @@ func (p *SimpleGrainPool[V]) SendNegotiation() {
|
||||
p.remoteMu.RLock()
|
||||
hosts := make([]string, 0, len(p.remoteHosts)+1)
|
||||
hosts = append(hosts, p.hostname)
|
||||
remotes := make([]Host, 0, len(p.remoteHosts))
|
||||
remotes := make([]Host[V], 0, len(p.remoteHosts))
|
||||
for h, r := range p.remoteHosts {
|
||||
hosts = append(hosts, h)
|
||||
remotes = append(remotes, r)
|
||||
}
|
||||
p.remoteMu.RUnlock()
|
||||
|
||||
p.forAllHosts(func(remote Host) {
|
||||
p.forAllHosts(func(remote Host[V]) {
|
||||
knownByRemote, err := remote.Negotiate(hosts)
|
||||
|
||||
if err != nil {
|
||||
@@ -313,7 +359,7 @@ func (p *SimpleGrainPool[V]) SendNegotiation() {
|
||||
})
|
||||
}
|
||||
|
||||
func (p *SimpleGrainPool[V]) forAllHosts(fn func(Host)) {
|
||||
func (p *SimpleGrainPool[V]) forAllHosts(fn func(Host[V])) {
|
||||
p.remoteMu.RLock()
|
||||
rh := maps.Clone(p.remoteHosts)
|
||||
p.remoteMu.RUnlock()
|
||||
@@ -341,14 +387,14 @@ func (p *SimpleGrainPool[V]) broadcastOwnership(ids []uint64) {
|
||||
return
|
||||
}
|
||||
|
||||
p.forAllHosts(func(rh Host) {
|
||||
p.forAllHosts(func(rh Host[V]) {
|
||||
rh.AnnounceOwnership(p.hostname, ids)
|
||||
})
|
||||
log.Printf("%s taking ownership of %d ids", p.hostname, len(ids))
|
||||
// go p.statsUpdate()
|
||||
}
|
||||
|
||||
func (p *SimpleGrainPool[V]) getOrClaimGrain(id uint64) (Grain[V], error) {
|
||||
func (p *SimpleGrainPool[V]) getOrClaimGrain(ctx context.Context, id uint64) (Grain[V], error) {
|
||||
p.localMu.RLock()
|
||||
grain, exists := p.grains[id]
|
||||
p.localMu.RUnlock()
|
||||
@@ -356,14 +402,15 @@ func (p *SimpleGrainPool[V]) getOrClaimGrain(id uint64) (Grain[V], error) {
|
||||
return grain, nil
|
||||
}
|
||||
|
||||
grain, err := p.spawn(id)
|
||||
grain, err := p.spawn(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
go p.broadcastOwnership([]uint64{id})
|
||||
p.localMu.Lock()
|
||||
p.grains[id] = grain
|
||||
p.localMu.Unlock()
|
||||
go p.broadcastOwnership([]uint64{id})
|
||||
|
||||
return grain, nil
|
||||
}
|
||||
|
||||
@@ -371,36 +418,49 @@ func (p *SimpleGrainPool[V]) getOrClaimGrain(id uint64) (Grain[V], error) {
|
||||
// var ErrNotOwner = fmt.Errorf("not owner")
|
||||
|
||||
// Apply applies a mutation to a grain.
|
||||
func (p *SimpleGrainPool[V]) Apply(id uint64, mutation ...proto.Message) (*MutationResult[*V], error) {
|
||||
grain, err := p.getOrClaimGrain(id)
|
||||
func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[V], error) {
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mutations, err := p.mutationRegistry.Apply(grain, mutation...)
|
||||
mutations, err := p.mutationRegistry.Apply(ctx, grain, mutation...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.storage != nil {
|
||||
go func() {
|
||||
if err := p.storage.AppendEvent(id, mutation...); err != nil {
|
||||
if err := p.storage.AppendMutations(id, mutation...); err != nil {
|
||||
log.Printf("failed to store mutation for grain %d: %v", id, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
for _, listener := range p.listeners {
|
||||
go listener.AppendMutations(id, mutations...)
|
||||
}
|
||||
result, err := grain.GetCurrentState()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &MutationResult[*V]{
|
||||
Result: result,
|
||||
|
||||
return &MutationResult[V]{
|
||||
Result: *result,
|
||||
Mutations: mutations,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Get returns the current state of a grain.
|
||||
func (p *SimpleGrainPool[V]) Get(id uint64) (*V, error) {
|
||||
grain, err := p.getOrClaimGrain(id)
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -408,7 +468,7 @@ func (p *SimpleGrainPool[V]) Get(id uint64) (*V, error) {
|
||||
}
|
||||
|
||||
// OwnerHost reports the remote owner (if any) for the supplied cart id.
|
||||
func (p *SimpleGrainPool[V]) OwnerHost(id uint64) (Host, bool) {
|
||||
func (p *SimpleGrainPool[V]) OwnerHost(id uint64) (Host[V], bool) {
|
||||
p.remoteMu.RLock()
|
||||
defer p.remoteMu.RUnlock()
|
||||
owner, ok := p.remoteOwners[id]
|
||||
@@ -423,7 +483,7 @@ func (p *SimpleGrainPool[V]) Hostname() string {
|
||||
// Close notifies remotes that this host is shutting down.
|
||||
func (p *SimpleGrainPool[V]) Close() {
|
||||
|
||||
p.forAllHosts(func(rh Host) {
|
||||
p.forAllHosts(func(rh Host[V]) {
|
||||
rh.Close()
|
||||
})
|
||||
|
||||
|
||||
+3
-3
@@ -7,7 +7,7 @@ import (
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/gogo/protobuf/proto"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type StateStorage struct {
|
||||
@@ -34,14 +34,14 @@ func NewState(registry MutationRegistry) *StateStorage {
|
||||
|
||||
var ErrUnknownType = errors.New("unknown type")
|
||||
|
||||
func (s *StateStorage) Load(r io.Reader, onMessage func(msg proto.Message)) error {
|
||||
func (s *StateStorage) Load(r io.Reader, onMessage func(msg proto.Message, timeStamp time.Time)) error {
|
||||
var err error
|
||||
var evt *StorageEvent
|
||||
scanner := bufio.NewScanner(r)
|
||||
for err == nil {
|
||||
evt, err = s.Read(scanner)
|
||||
if err == nil {
|
||||
onMessage(evt.Mutation)
|
||||
onMessage(evt.Mutation, evt.TimeStamp)
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user